A search box that knows what you can make¶
The player has forty things in the bag and wants to know which of them are worth keeping. Type @craftable and the bag shows exactly the ingredients for something they could finish right now — not every item whose name happens to contain those letters.
Recipe
Systems required: Crafting, Inventory. Package: Inventory, Pickups & Crafting, or Complete. Shape: one class you drop into a project that already exists. No prefab, no bespoke scene. Public API only. It assumes: you have a search box wired to SceneInventoryService.Search, and a list of the recipes the actor knows. It installs itself into the service on enable. Once you change it, it is your code. Copying and editing is the intended path — so a modified recipe is yours to maintain and debug. Support covers the framework's behaviour, not a copy of this class.
The part that is not obvious¶
IInventorySearch is two things in a trench coat:
int[] Search(IReadOnlyList<InventorySlot> slots, string query);
The slot list is the data. The query is a string the player typed, handed to you uninterpreted. Nothing in that signature says substring. The shipped strategy chooses to match names, categories and tags because that is a sensible default, not because the seam requires it.
A search box is a query language you have not written yet
Anything your game can answer about an item can become a word in the box. This recipe teaches it two, and the interesting one reaches into a different system entirely:
@craftable— an input to a recipe this actor can complete now, asked through the crafting preflight rather than guessed at from the bag.@ingredient— an input to any known recipe, whether or not the rest of it is in the bag.
Either can be followed by ordinary text. @craftable iron is both filters at once.
It is a replacement, not a decoration, and that is forced¶
SceneInventoryService.SetSearch installs a strategy. There is no getter to read the one already there, and the shipped DefaultInventorySearch is internal. So a custom search cannot wrap what it displaced, and has to answer every case the default answered.
The empty query is the one that bites
The default returns every index for a null, empty or whitespace query — including empty slots. A search that handled only its own two words would leave a player who cleared the box staring at an empty bag, and a search that filtered out the holes would silently reflow a grid inventory.
Same rule the floor store and the rolling backup store ran into, in three different systems now: decorating an interface means re-answering all of it, not the parts you came for. The plain-text half of this class is a transcription of the behaviour it replaced, and it is there because there is nothing left to fall through to.
The strategy is per-service; the question is per-actor¶
SceneInventoryService.Search(owner, container, query) knows whose bag it is, and then calls the strategy with the slots alone.
So @craftable answers for the actor named on the component, whichever bag is searched
For a single player that is invisible and the recipe is simply correct. For a party, a shared stash, or split screen it is not — and there is no seam here that would fix it, because the owner is dropped one call above.
Worth knowing before you build a stash UI on top of it. The workaround is not to install this globally but to call it yourself with the slots you already have.
Smaller things worth knowing¶
Unticking it stands it down rather than removing it
SetSearch ignores a null, deliberately, so installing is one-way — there is no uninstall. Rather than leave the service holding something inert, a disabled component behaves exactly like the search it replaced: plain text still works, an empty query still returns everything, and the two words are just text. Do not destroy it; the service keeps the reference either way.
A preflight is a read, but it is not free
CanCraft raises no events and starts no jobs — the rejection events belong to the enqueue path, not to the query — so calling it from a search box is safe. It runs once per known recipe per query, never per slot. With a large recipe book, debounce the box.
Do not cache the answer. The whole point is that it changes as the bag does, and a stale @craftable is worse than a slow one. And note the one thing that is your code, not the framework's: a validator of your own that counts calls will see the search box.
A near miss must not become a filter
@craftableiron is one word, and it is a typo, not a query. The token is only a token when the next character is a space or there is no next character.
This is the check that nearly went unproven. The first version tested @craftablish, which is not a near miss at all — it diverges from @craftable at the ninth character, so it was ordinary text that happened to match nothing, and the check passed without ever exercising the guard. The negative control is what exposed it.
No crafting service means @craftable matches nothing, and says so once
That is the truthful answer — with no crafting service you can craft nothing — but it looks exactly like an empty bag. The Editor warning is latched, because a missing reference is a setup mistake rather than a per-keystroke event.
The guid is the only handle the two systems share
ItemRef carries a guid and a quantity; ItemDefinition carries a guid. Nothing else lines up, so the filter is a set of guid strings — rebuilt per query into a set the component keeps, so typing does not allocate one per keystroke.
Drop it in¶
using System;
using System.Collections.Generic;
using RevGaming.RevFramework.Crafting.Core;
using RevGaming.RevFramework.Crafting.UnityIntegration;
using RevGaming.RevFramework.Inventory.Abstractions;
using RevGaming.RevFramework.Inventory.Containers;
using RevGaming.RevFramework.Inventory.Data;
using RevGaming.RevFramework.Inventory.UnityIntegration;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.CraftableSearch
{
/// <summary>
/// A bag search that understands <c>@craftable</c> — type it and the box shows the ingredients for
/// something you can finish right now, not every item whose name happens to contain the letters.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
/// <b>Inventory</b>, <b>Crafting</b> — same package. Public API only. Put it anywhere in the scene
/// and point it at the inventory service, the crafting service and the actor whose recipes should
/// answer.</para>
///
/// <para><b>The seam is handed a raw string, and that is the whole idea.</b>
/// <see cref="IInventorySearch.Search"/> takes the query the player typed and the slot list, and
/// returns the indices that matched. Nothing about it says <i>substring</i>. So the search box can
/// be a small query language over anything the rest of your game knows — here, the crafting
/// preflight.</para>
///
/// <para><b>Two words the box learns.</b> <c>@craftable</c> keeps items that are an input to a
/// recipe this actor could complete <i>now</i>; <c>@ingredient</c> keeps items that are an input to
/// any known recipe, whether or not the rest of it is in the bag. Either can be followed by
/// ordinary text — <c>@craftable iron</c> is both filters at once.</para>
///
/// <para><b>It is a replacement, not a decoration, and that is forced.</b>
/// <c>SceneInventoryService.SetSearch</c> installs a strategy and there is no getter to read the
/// one already there, while the shipped <c>DefaultInventorySearch</c> is <c>internal</c>. So a
/// custom search cannot wrap what it displaced and has to answer <b>every</b> case the default
/// answered — including the empty query, which returns every slot. A search that handled only its
/// own two words would leave a player who cleared the box looking at an empty bag. Same rule
/// <c>FloorStore</c> and <c>RollingBackupStore</c> ran into: <b>decorating an interface means
/// re-answering all of it, not the parts you came for.</b></para>
///
/// <para><b>The strategy is per-service; the question is per-actor.</b>
/// <c>SceneInventoryService.Search(owner, container, query)</c> knows whose bag it is and then
/// calls the strategy with the slots alone. So <c>@craftable</c> is answered for the actor named on
/// this component, whichever bag is being searched. For one player that is invisible. For a party,
/// a shared stash or a split screen it is not, and there is no seam here that would fix it.</para>
///
/// <para><b>Disabling it stands it down rather than removing it.</b> <c>SetSearch</c> ignores a
/// null, deliberately, so installing is one-way: there is no uninstall. Unticking the component
/// therefore makes it behave exactly like the search it replaced instead of leaving the service
/// holding something inert. Do not destroy it — the service keeps the reference either way.</para>
///
/// <para><b>A preflight per known recipe, per keystroke.</b> <c>CanCraft</c> raises no events and
/// starts no jobs, so calling it from a search box is a read — but it is not free, and a validator
/// of your own that counts calls will see them. With a large recipe book, debounce the search box;
/// do not cache the answer, because the point of it is that it changes as the bag does.</para>
/// </remarks>
[DisallowMultipleComponent]
public sealed class CraftableSearch : MonoBehaviour, IInventorySearch
{
/// <summary>Keeps items that are an input to a recipe the actor can complete right now.</summary>
public const string CraftableToken = "@craftable";
/// <summary>Keeps items that are an input to any known recipe.</summary>
public const string IngredientToken = "@ingredient";
[Tooltip("The service this installs itself into. Its search strategy is global, so there is " +
"one of these per service, not one per actor.")]
[SerializeField] private SceneInventoryService inventory;
[Tooltip("Answers whether a recipe can be completed. Without it @craftable matches nothing, " +
"which is the truthful answer rather than a silent fallback to plain text.")]
[SerializeField] private CraftingService crafting;
[Tooltip("Whose recipes and whose bag the preflight asks about. Leave empty to use the object " +
"this component is on.")]
[SerializeField] private GameObject owner;
[Tooltip("The recipes this actor knows. A game with a recipe book should drive this from " +
"progression through SetRecipes rather than authoring it here.")]
[SerializeField] private RecipeCore[] knownRecipes = Array.Empty<RecipeCore>();
// Rebuilt per query and reused, so typing does not allocate a set per keystroke. It holds
// item GUIDs rather than definitions: ItemRef carries a guid and nothing else, so the guid is
// the only handle the two systems share.
private readonly HashSet<string> wanted = new(StringComparer.Ordinal);
private readonly List<int> hits = new(16);
private bool warnedNoInventory;
#if UNITY_EDITOR
// Latched, because a missing service is a setup mistake rather than a per-query event, and a
// warning on every keystroke buries the one that matters. The field is editor-only so a
// player build does not carry a byte of it.
private bool warnedNoCrafting;
#endif
private void OnEnable()
{
if (inventory)
{
inventory.SetSearch(this);
return;
}
// Said out loud, because this failure has no other symptom. With the field empty SetSearch
// is never called, the shipped strategy keeps answering, and "@craftable" is then ordinary
// text that matches no item name -- which reads to a player as "you can make nothing" and
// to a developer as a working search box. Latched: a half-wired scene would otherwise warn
// on every enable.
if (warnedNoInventory) return;
warnedNoInventory = true;
Debug.LogWarning(
$"[Cookbook] '{name}' has no {nameof(SceneInventoryService)} assigned, so it never " +
$"installs itself and \"{CraftableToken}\" is matched as plain text. Assign one.", this);
}
/// <summary>
/// Replaces the recipe list this search answers from.
/// </summary>
/// <remarks>
/// The framework has no notion of a recipe book, so what an actor "knows" is your game's
/// answer, not one this component can look up. A null list is treated as an empty one.
/// </remarks>
/// <param name="recipes">The recipes to consider known.</param>
public void SetRecipes(IReadOnlyList<RecipeCore> recipes)
{
if (recipes == null || recipes.Count == 0)
{
knownRecipes = Array.Empty<RecipeCore>();
return;
}
var copy = new RecipeCore[recipes.Count];
for (int i = 0; i < recipes.Count; i++) copy[i] = recipes[i];
knownRecipes = copy;
}
/// <summary>
/// Returns the indices of the slots matching <paramref name="query"/>.
/// </summary>
/// <remarks>
/// <para>An empty or whitespace query returns every index, which is the contract the strategy
/// this replaces documents and honours. Getting that wrong is how clearing the search box
/// empties the bag.</para>
/// <para>Plain text falls through to the same matching the default performs — display name,
/// then category, then tags, case-insensitively — because there is nothing left to fall through
/// to.</para>
/// </remarks>
/// <param name="slots">Slots to search.</param>
/// <param name="query">Search query, optionally led by one of the two tokens.</param>
/// <returns>Indices of slots that matched.</returns>
public int[] Search(IReadOnlyList<InventorySlot> slots, string query)
{
if (slots == null) return Array.Empty<int>();
string text = query == null ? string.Empty : query.Trim();
// Unticked is a stand-down, not an uninstall: behave as the search this displaced.
bool tokensLive = isActiveAndEnabled;
bool craftableOnly = false;
bool ingredientOnly = false;
if (tokensLive)
{
while (true)
{
if (StartsWithToken(text, CraftableToken, out string rest)) { craftableOnly = true; text = rest; continue; }
if (StartsWithToken(text, IngredientToken, out rest)) { ingredientOnly = true; text = rest; continue; }
break;
}
}
// @craftable is the narrower claim, so it wins when both are typed.
if (craftableOnly) ingredientOnly = false;
if (!craftableOnly && !ingredientOnly && text.Length == 0)
return AllIndices(slots.Count);
if (craftableOnly || ingredientOnly) CollectWanted(craftableOnly);
string needle = text.Length == 0 ? null : text.ToLowerInvariant();
hits.Clear();
for (int i = 0; i < slots.Count; i++)
{
var def = slots[i].stack.def;
if (!def) continue;
if ((craftableOnly || ingredientOnly) && !wanted.Contains(def.guid ?? string.Empty)) continue;
if (needle != null && !MatchesText(def, needle)) continue;
hits.Add(i);
}
return hits.ToArray();
}
/// <summary>
/// Fills <see cref="wanted"/> with the input GUIDs of the recipes that qualify.
/// </summary>
/// <remarks>
/// <paramref name="craftableOnly"/> runs the preflight per recipe and keeps only the ones that
/// come back with at least one craft available. That is the expensive half, and it is also the
/// only half that can answer the question honestly: "do I have enough of everything, is there
/// room for the output, and does every validator agree" is exactly what the preflight computes,
/// and re-deriving it from the bag would be a second opinion that drifts.
/// </remarks>
/// <param name="craftableOnly">True to keep only recipes that can be completed now.</param>
private void CollectWanted(bool craftableOnly)
{
wanted.Clear();
var actor = owner ? owner : gameObject;
for (int r = 0; r < knownRecipes.Length; r++)
{
var recipe = knownRecipes[r];
if (!recipe || !recipe.IsValid) continue;
if (craftableOnly)
{
if (!crafting) continue;
if (!crafting.CanCraft(actor, recipe)) continue;
}
var inputs = recipe.Inputs;
if (inputs == null) continue;
for (int i = 0; i < inputs.Count; i++)
{
string guid = inputs[i].guid;
if (!string.IsNullOrEmpty(guid)) wanted.Add(guid);
}
}
#if UNITY_EDITOR
if (craftableOnly && !crafting && !warnedNoCrafting)
{
warnedNoCrafting = true;
Debug.LogWarning(
$"[CraftableSearch] No crafting service assigned, so \"{CraftableToken}\" matches " +
"nothing. That is truthful — you can craft nothing — but it looks identical to an " +
"empty bag, so it is worth saying once.", this);
}
#endif
}
/// <summary>
/// The same matching the strategy this replaces performs: display name, then category, then tags.
/// </summary>
/// <remarks>
/// Transcribed rather than reused, because <c>DefaultInventorySearch</c> is internal. If you
/// only want the tokens and are happy with the shipped matching for everything else, this is
/// the part to keep rather than the part to improve.
/// </remarks>
/// <param name="def">The item in the slot.</param>
/// <param name="needle">The query text, already lowercased and trimmed.</param>
private static bool MatchesText(ItemDefinition def, string needle)
{
var name = (def.displayName ?? def.name) ?? string.Empty;
if (name.Length > 0 && name.ToLowerInvariant().Contains(needle)) return true;
var cat = def.NormalizedCategory ?? string.Empty;
if (cat.Contains(needle)) return true;
var tags = def.NormalizedTags;
if (tags != null)
{
foreach (var t in tags)
{
if (t.Contains(needle)) return true;
}
}
return false;
}
/// <summary>
/// True when <paramref name="text"/> opens with <paramref name="token"/> as a whole word.
/// </summary>
/// <remarks>
/// The word test matters: without it <c>@craftablish</c> would be read as the token followed by
/// nothing, and a typo would silently become a filter.
/// </remarks>
/// <param name="text">The query so far.</param>
/// <param name="token">The token to strip.</param>
/// <param name="rest">What is left after the token and any following space.</param>
/// <returns>True when the token was found and stripped.</returns>
private static bool StartsWithToken(string text, string token, out string rest)
{
rest = text;
if (text.Length < token.Length) return false;
if (!text.StartsWith(token, StringComparison.OrdinalIgnoreCase)) return false;
if (text.Length > token.Length && text[token.Length] != ' ') return false;
rest = text.Substring(token.Length).TrimStart();
return true;
}
/// <summary>
/// Every index, for the empty query.
/// </summary>
private static int[] AllIndices(int count)
{
if (count <= 0) return Array.Empty<int>();
var all = new int[count];
for (int i = 0; i < count; i++) all[i] = i;
return all;
}
}
}
Wiring it up¶
- Put the component anywhere in the scene — it does not need to be on the player — and assign the inventory service, the crafting service and the actor whose recipes should answer. Leave
ownerempty to use the object it sits on. Both services are checked and both warn once. An empty inventory field meansSetSearchis never called, so@craftablebecomes ordinary text that matches nothing — which reads as an empty bag rather than as a component that was never installed. - Fill in the recipes the actor knows, or drive them from your progression with
SetRecipes. The framework has no notion of a recipe book, so what an actor knows is your game's answer. - Wire your search box to
SceneInventoryService.Search(owner, container, query)as usual. Nothing about the call site changes — the box just understands more words than it did. - Put the two words somewhere the player can find them. A query language nobody knows about is a feature nobody uses; a placeholder of
search, or @craftablecosts nothing. - If you want the shipped matching back for everything except the tokens, keep
MatchesTextand change nothing else. It is a transcription of the default on purpose.
What it deliberately does not do¶
It does not tell you what you are short of. @missing sounds like the obvious third word and it cannot exist here: the seam returns indices into the bag, and the thing you are missing is by definition not in the bag. That belongs in a recipe list UI, not a bag filter.
It does not know about quantities. An item is an input or it is not. "I have some but not enough" is a real distinction and CraftProbe reports it — byItems is the bound the inputs allow — but expressing it as a slot filter would mean a third word whose meaning changes per recipe.
It does not sort. IInventorySorter is the seam next door, and floating craftable items to the top rather than filtering to them is a genuinely different feel. One class, one seam.
It does not parse anything clever. No boolean operators, no ranges, no quoting. Two words and a remainder is a query language that fits in one method and can be explained in one line, which is the point at which a reader can lift it.
Related¶
- Inventory — containers, slots, and the search and sort seams.
- Crafting — the preflight, and what
CanCraftactually checks before it answers. - The purchase lands on the floor — the same re-answer-every-method rule, in Economy.
- A save that survives a crash mid-write — the same rule again, over a five-method interface, with a worked list of what forwarding gets wrong.
- Break an item back down — the other recipe that reads the recipes you already wrote and asks them a question they were not authored to answer.