A chest that stays looted¶
Open it once and it is empty forever — including after a save and a reload, and including the chests the player walked past without opening.
Recipe
Systems required: Loot, plus Core for the save side. Package: Inventory, Pickups & Crafting, or Complete. Shape: one class you drop into a project that already exists. No scene, no prefab, no setup ritual. Public API only. 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¶
Rolling a table once is easy. Remembering is the part people bolt on afterwards and regret — a static HashSet of opened chests, a manager keeping a list, a flag on a prefab that resets on load. Each of those is a second source of truth that has to be saved separately and eventually disagrees with the first.
IRevSaveParticipant makes "this chest is empty" ordinary save state: same file, same envelope, same failure reporting as Inventory or Currency, and four members to join. A chest cannot then be re-looted by quitting to the menu, which is the oldest exploit in the genre.
The key identifies this chest, not this prefab
Every participant needs its own key. Two sharing one is a real error and the coordinator reports it — so a prefab with a hard-coded key works for exactly one chest and is silently wrong for the second. The id is authored per instance, and the component says so loudly when it is left blank.
This is the whole difference between a recipe that works in your test scene and one that works in a level.
Every chest writes a section, and the cheaper alternative is a trap
It is tempting to have Capture return null until the chest is opened: a null payload is recorded as a skipped section rather than an empty one, so a world full of unopened chests would add nothing to the save file.
Do not do it — a chest that writes nothing can never be un-looted. Restore only reaches participants whose key is in the file, so a save taken while the chest was shut leaves nothing to restore from. Load that save after opening the chest and the component keeps _looted = true: the chest stays empty, the items you took stay gone, and the next save bakes the divergence in. It is one-way — a chest can go looted and never back.
So Capture returns "fresh" rather than null, and the whole cost of that is a few bytes per chest. This is the general shape, not a quirk of chests: a participant that skips its default state cannot be restored to it. Every shipped participant writes for an owner that exists, and returns null only when there is nothing in the world to describe at all.
Marked before the roll, not after. A loot modifier or a Granted listener is customer code and may open this same chest again from inside the delivery. Marking afterwards leaves that window open, and the window is exactly where a duplication exploit lives.
Refuse before you mutate. Restore validates the version and payload while the component is still untouched, so a refusal leaves the section carryable and a save from a newer build survives an older build reading it. Here the mutation is a single assignment that cannot fail halfway, so there is no partial state to report — which is the easy case of a decision every participant has to make.
Drop it in¶
using System;
using RevGaming.RevFramework.Core.Save;
using RevGaming.RevFramework.Loot.Core;
using RevGaming.RevFramework.Loot.UnityIntegration;
using UnityEngine;
namespace RevGaming.RevFramework.Cookbook.LootedOnce
{
/// <summary>
/// A chest that gives up its table once and stays empty afterwards — including across a save and
/// a reload, and including the ones the player never opened.
/// </summary>
/// <remarks>
/// <para><b>Recipe.</b> One class, dropped into a project that already exists. Systems required:
/// <b>Loot</b>, plus Core for the save side. Public API only.</para>
///
/// <para><b>The composition.</b> Rolling once is easy; remembering is the part people bolt on
/// afterwards and regret. <see cref="IRevSaveParticipant"/> makes "this chest is empty" ordinary
/// save state, in the same file and with the same failure reporting as everything else — so a
/// chest cannot be re-looted by quitting to the menu, which is the oldest exploit in the genre.
/// That holds as long as the chest is in the scene when the save is applied; see <b>registration
/// is a lifetime</b> below.</para>
///
/// <para><b>The key must identify this chest, not this prefab.</b> Every participant in a save file
/// needs its own key: two sharing one is a real error and the coordinator reports it. A prefab with
/// a hard-coded key is therefore fine for exactly one chest and silently wrong for the second, so
/// the id is authored per instance and the component says so loudly when it is left blank. This is
/// the whole difference between a recipe that works in your test scene and one that works in a
/// level. Authoring the string by hand is not the only option: <see cref="Core.Identity.StableId"/>
/// ships in Core, mints an id per scene instance in the editor and serializes it into the scene
/// file, and comes with <c>RevFramework ▸ Validate ▸ Duplicate Stable Ids</c> for the collision
/// this paragraph is about. Its one caveat is the spawned case — an instance created at runtime
/// gets a different id every launch unless <c>AssignId</c> hands it a durable one.</para>
///
/// <para><b>Registration is a lifetime, not a setup step.</b> A chest joins the manager in
/// <c>OnEnable</c> and leaves in <c>OnDisable</c>, so every chest has to be loaded and enabled
/// <i>before</i> the save is applied: load the scene, then call <c>Load</c>. A chest that arrives
/// afterwards — an additive or streamed sub-scene — is never handed its section. Nothing reports a
/// problem, because the section is carried over intact, but the live chest reads as fresh and can
/// be looted a second time. A streamed world wants this flag owned by something scene-independent
/// rather than by the chest itself.</para>
///
/// <para><b>Every chest writes a section, opened or not.</b> Capturing <c>null</c> while untouched
/// is the tempting optimisation, and it is one-way: the coordinator never calls <c>Restore</c> for
/// a participant with no section, so loading a save taken <i>before</i> this chest was opened would
/// leave it looted for good while the inventory rolled back around it. A handful of bytes per chest
/// is what it costs for the save file to be the whole truth in both directions.</para>
///
/// <para><b>Refuse before you mutate.</b> Restore validates the version and the payload while this
/// component is still untouched, so a refusal leaves the section carryable — a save from a newer
/// build is preserved rather than destroyed by an older one reading it. The same contract the shop
/// recipe follows, and it matters here for a smaller-sounding reason: a payload this build does not
/// recognise, read as "not looted", is a chest that quietly refills.</para>
/// </remarks>
[DisallowMultipleComponent]
public sealed class LootedOnceContainer : MonoBehaviour, IRevSaveParticipant
{
[Tooltip("Save manager to join. Leave empty to find one in the scene on enable.")]
[SerializeField] private RevSaveManager saveManager;
[Tooltip("Loot service that rolls and delivers. Leave empty to find one in the scene on first use.")]
[SerializeField] private LootService loot;
[Tooltip("What this chest holds.")]
[SerializeField] private LootTable table;
[Tooltip("Unique id for THIS chest -- not shared with any other. Two participants with one key " +
"is an error the save coordinator reports.")]
[SerializeField] private string containerId = "";
[Tooltip("Key prefix. Use your own -- revframework.* belongs to the framework.")]
[SerializeField] private string keyPrefix = "mygame.chest.";
private bool _looted;
/// <inheritdoc />
public string Key => keyPrefix + containerId;
/// <inheritdoc />
public int Version => 1;
/// <summary>Whether this chest has already given up its contents.</summary>
/// <remarks>A load changes this without raising anything — participants restore state without
/// emitting the events that state would normally raise — so a lid, a material swap or an
/// interaction prompt driven by it reconciles from <c>RevSaveManager.LoadCompleted</c> rather
/// than from an event on the chest.</remarks>
public bool IsLooted => _looted;
private void OnEnable()
{
if (string.IsNullOrWhiteSpace(containerId))
{
Debug.LogWarning($"[{nameof(LootedOnceContainer)}] '{name}' has no container id, so it " +
"cannot be saved separately from any other chest. Give each one its own.", this);
return;
}
if (!saveManager) saveManager = FindAnyObjectByType<RevSaveManager>();
if (saveManager) saveManager.Register(this);
else
Debug.LogWarning($"[{nameof(LootedOnceContainer)}] '{name}' found no " +
$"{nameof(RevSaveManager)}, so nothing about it is saved. The lookup " +
"runs once per enable, so a manager created after this chest is not " +
"picked up either.", this);
}
private void OnDisable()
{
if (saveManager) saveManager.Unregister(this);
}
/// <summary>
/// Rolls the table for whoever opened the chest, once and only once.
/// </summary>
/// <remarks>
/// <para><b>The chest is spent whether or not the loot lands.</b> What comes back is the roll,
/// not the delivery: with pickups enabled an award is spawned rather than handed over, and with a
/// full bag nothing reaches the player at all — the returned result looks the same either way,
/// which is why it must not drive a "you received" toast. <see cref="LootService.Undelivered"/>
/// is the channel that carries those, with one gap worth knowing: an opener destroyed during
/// the roll is delivered nothing and raises nothing.</para>
///
/// <para><b><see cref="LootResult.Empty"/> means five things</b>, of which "already looted" is
/// only one — no table, no opener, no <see cref="LootService"/> and a roll that legitimately
/// produced nothing all look identical from here.</para>
/// </remarks>
/// <param name="opener">Who receives the loot.</param>
/// <returns>
/// What the table produced — not necessarily what the player received — or
/// <see cref="LootResult.Empty"/> when the chest gave nothing.
/// </returns>
public LootResult Open(GameObject opener)
{
if (_looted || !table || !opener)
return LootResult.Empty;
// Include, matching every framework resolver of this service: a LootService parked on an
// inactive object is a supported setup, and the bare overload would not find it there.
if (!loot) loot = FindAnyObjectByType<LootService>(FindObjectsInactive.Include);
if (!loot)
{
Debug.LogWarning($"[{nameof(LootedOnceContainer)}] '{name}' found no " +
$"{nameof(LootService)}, so it has nothing to roll with. The chest is " +
"left unspent.", this);
return LootResult.Empty;
}
// Marked before the roll, not after. A modifier or a listener is customer code and may open
// this same chest again from inside the delivery; marking afterwards leaves that window open,
// and the window is exactly where a duplication exploit lives.
_looted = true;
return loot.RollAndGrant(table, opener, container: null, spawnAt: transform.position);
}
/// <inheritdoc />
// "fresh" rather than null. A null payload is recorded as a skipped section and no section is
// written -- and a participant with no section is never restored, so an untouched chest that
// saved nothing could never be un-looted by loading that save.
public string Capture() => _looted ? "looted" : "fresh";
/// <inheritdoc />
public void Restore(string payload, int version)
{
if (version > Version)
throw new NotSupportedException(
$"Chest section '{Key}' was written by a newer build than this one can read.");
if (string.IsNullOrWhiteSpace(payload))
throw new InvalidOperationException($"Chest section '{Key}' is empty.");
if (payload != "looted" && payload != "fresh")
throw new InvalidOperationException(
$"Chest section '{Key}' carries an unrecognised payload. Refused rather than read " +
"as un-looted, which would refill the chest and report success.");
// One assignment, and nothing before it can fail halfway -- so there is no partial state to
// report and no need for RevSavePartialRestoreException here. Worth saying out loud, because
// "which exception" is a decision every participant has to make and this is the easy case.
_looted = payload == "looted";
}
}
}
Wiring it up¶
- Put the component on the chest, assign its table, and give it a unique container id.
- Call
Open(player)from whatever your interaction system already uses. - Nothing else — it finds the save manager and registers itself.
What it deliberately does not do¶
It does not respawn. A chest that refills after a week is a different feature and wants a timestamp, not a flag. The payload is deliberately the simplest thing that can be true.
It does not hide itself when empty. Visuals are yours: IsLooted is public so an open lid, a different material or a disabled prompt can read it.
It does not spread its id automatically. No auto-generated GUID on Awake, because an id that changes when a scene is reloaded is worse than a blank one — it orphans the save section silently, while a blank one warns.
Related¶
- Loot — tables, rolling and delivery.
- Save — participants, keys, the envelope and what carry-over does with sections nobody claimed.
- A shop that remembers — the same participant pattern with a larger payload and a real refusal contract.