aboutsummaryrefslogtreecommitdiff
path: root/internal/game
diff options
context:
space:
mode:
Diffstat (limited to 'internal/game')
-rw-r--r--internal/game/act_combine.go35
-rw-r--r--internal/game/act_fletch.go72
-rw-r--r--internal/game/casino.go59
-rw-r--r--internal/game/casino_refund_regression_test.go164
-rw-r--r--internal/game/economy.go175
-rw-r--r--internal/game/journal_damage_regression_test.go123
-rw-r--r--internal/game/journal_failure_regression_test.go15
-rw-r--r--internal/game/production_engine.go76
-rw-r--r--internal/game/production_index.go109
-rw-r--r--internal/game/production_output_regression_test.go186
10 files changed, 869 insertions, 145 deletions
diff --git a/internal/game/act_combine.go b/internal/game/act_combine.go
index bc9b6de..b16cf7a 100644
--- a/internal/game/act_combine.go
+++ b/internal/game/act_combine.go
@@ -100,6 +100,15 @@ func (g *Game) advanceCombine(sess *net.Session, p *player.Player) {
return
}
+ // A full inventory must never destroy materials: verify the output
+ // can be placed BEFORE collecting byproducts and consuming.
+ outDef, _ := g.ItemStore.Load(item.ID)
+ if !craftOutputFits(p, item.ID, outDef) {
+ 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.
@@ -112,26 +121,12 @@ func (g *Game) advanceCombine(sess *net.Session, p *player.Player) {
outputQty = 1
}
- outDef, _ := g.ItemStore.Load(item.ID)
- placed := false
- if outDef != nil && outDef.Stackable {
- for i := 0; i < 28; i++ {
- slot := p.InvSlot(i)
- if slot != nil && slot.ItemID == item.ID {
- slot.Quantity += outputQty
- placed = true
- break
- }
- }
- }
- if !placed {
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- sess.WriteLine("Your inventory is too full!")
- g.cancelAction(p)
- return
- }
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: item.ID, Quantity: outputQty})
+ 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
diff --git a/internal/game/act_fletch.go b/internal/game/act_fletch.go
index c52aae1..9796bda 100644
--- a/internal/game/act_fletch.go
+++ b/internal/game/act_fletch.go
@@ -34,31 +34,22 @@ func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, def *item.It
totalOutput := 0
skill := player.SkillName(c.EffectiveSkill())
tmpItem := &item.ItemDef{Craft: item.CraftList{*c}}
+ inventoryFull := false
for {
if !g.canDoFletchItem(p, tmpItem) {
break
}
-
- placed := false
- if def.Stackable {
- for i := 0; i < 28; i++ {
- slot := p.InvSlot(i)
- if slot != nil && slot.ItemID == def.ID {
- consumeIngredients(c, p.CountItem, p.RemoveItem)
- slot.Quantity += outputQty
- placed = true
- break
- }
- }
+ // A full inventory must never destroy materials: check placement
+ // BEFORE consuming the batch.
+ if !craftOutputFits(p, def.ID, def) {
+ inventoryFull = true
+ break
}
- if !placed {
- consumeIngredients(c, p.CountItem, p.RemoveItem)
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- break
- }
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: def.ID, Quantity: outputQty})
+
+ if !consumeIngredients(c, p.CountItem, p.RemoveItem) {
+ break
}
+ placeCraftOutput(p, def.ID, def, outputQty)
if c.XP > 0 {
g.awardSkillXP(sess, p, skill, c.XP)
@@ -76,7 +67,11 @@ func (g *Game) doInstantFletch(sess *net.Session, p *player.Player, def *item.It
}
if batches == 0 {
- sess.WriteLine("You don't have the materials for that.")
+ if inventoryFull {
+ sess.WriteLine("Your inventory is too full!")
+ } else {
+ sess.WriteLine("You don't have the materials for that.")
+ }
return
}
@@ -158,29 +153,26 @@ func (g *Game) advanceFletch(sess *net.Session, p *player.Player) {
outputQty = 1
}
+ // A full inventory must never destroy materials: verify the output can
+ // be placed BEFORE consuming the batch.
+ if !craftOutputFits(p, itemID, def) {
+ sess.WriteLine("Your inventory is too full!")
+ g.cancelBgAction(p)
+ return
+ }
+
skill := player.SkillName(c.EffectiveSkill())
- placed := false
- if def.Stackable {
- for i := 0; i < 28; i++ {
- slot := p.InvSlot(i)
- if slot != nil && slot.ItemID == def.ID {
- consumeIngredients(c, p.CountItem, p.RemoveItem)
- slot.Quantity += outputQty
- placed = true
- break
- }
- }
+ if !consumeIngredients(c, p.CountItem, p.RemoveItem) {
+ g.cancelBgAction(p)
+ return
}
- if !placed {
- consumeIngredients(c, p.CountItem, p.RemoveItem)
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- sess.WriteLine("Your inventory is too full!")
- g.cancelBgAction(p)
- return
- }
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: def.ID, Quantity: outputQty})
+ if !placeCraftOutput(p, itemID, def, outputQty) {
+ // Defensive: the fits check above should have guaranteed a stack
+ // or slot.
+ sess.WriteLine("Your inventory is too full!")
+ g.cancelBgAction(p)
+ return
}
if c.XP > 0 {
diff --git a/internal/game/casino.go b/internal/game/casino.go
index 79d324a..133eb34 100644
--- a/internal/game/casino.go
+++ b/internal/game/casino.go
@@ -70,21 +70,32 @@ func (w casinoWallet) Wager(amount int) (casino.Wager, error) {
// The record stays open as an outstanding stake until the round
// resolves; recovery refunds open stakes after a crash.
w.g.Economy.registerStake(rec)
- return casino.Wager{Total: amount, Chips: chips, Credits: credits}, nil
+ return casino.Wager{Total: amount, Chips: chips, Credits: credits, ID: rec.ID}, nil
}
// PayCredits applies a payout durably. The journal record is written first;
// if the save fails, the record stays open and startup recovery re-pays it
// (the player is owed the settlement).
func (w casinoWallet) PayCredits(amount int) error {
- return w.payCreditsFlagged(amount, false)
+ return w.payCreditsFlagged(amount, false, nil)
+}
+
+// PayStakeCover applies a settlement that INCLUDES the return of the listed
+// stake wager IDs (a table hand pays its stake back inside the payout). The
+// record is journaled with that linkage, so a shutdown refund never returns
+// those stakes a second time; recovery re-applies the owed payout instead.
+func (w casinoWallet) PayStakeCover(amount int, stakeIDs []string) error {
+ return w.payCreditsFlagged(amount, false, stakeIDs)
}
// payCreditsFlagged journals and applies a credit payout; a settlement
// payout (refund=false) proves the round resolved during recovery, while a
// refund payout (refund=true) marks a stake RETURNED at shutdown/eviction
// (its chip part returns in chips, not through this credit payout).
-func (w casinoWallet) payCreditsFlagged(amount int, refund bool) error {
+// stakeIDs, when non-empty, records WHICH open stakes this settlement
+// returns (table hands); empty means the payout resolves the round without
+// a linked stake return (slots winnings).
+func (w casinoWallet) payCreditsFlagged(amount int, refund bool, stakeIDs []string) error {
if amount <= 0 {
return nil
}
@@ -96,7 +107,7 @@ func (w casinoWallet) payCreditsFlagged(amount int, refund bool) error {
// With the cap blocking, the balance is unchanged and the open record
// keeps the owed amount durable; startup recovery re-applies it once
// the balance has room below the cap.
- rec, err := w.g.Economy.startKind("payout", refund, w.p, 0, amount,
+ rec, err := w.g.Economy.startKind("payout", refund, stakeIDs, w.p, 0, amount,
creditsBefore, creditsAfter, chipsBefore, chipsBefore)
if err != nil {
// The journal is unavailable (persistent append failure). Last
@@ -168,6 +179,15 @@ func (w casinoWallet) payCreditsFlagged(amount int, refund bool) error {
// honors the resolution instead of refunding an already-resolved round.
func (w casinoWallet) ResolveStakes() {
unresolved := w.g.Economy.ResolveStakes(economyKey(w.p))
+ w.recordResolutionEvidence(unresolved)
+}
+
+// recordResolutionEvidence persists file-side resolution evidence for stake
+// records whose settled markers could not be journaled. The character file
+// then proves the round resolved even though the journal could not record
+// its marker, so recovery still honors the resolution instead of refunding
+// an already-resolved round.
+func (w casinoWallet) recordResolutionEvidence(unresolved []*economyRecord) {
if len(unresolved) == 0 {
return
}
@@ -186,6 +206,18 @@ func (w casinoWallet) ResolveStakes() {
}
}
+// ResolveStake settles ONE outstanding stake by wallet handle: the hand it
+// belongs to was paid (its return is inside the settlement) or lost, so it
+// must never be refunded. Unresolvable markers fall back to the same file-
+// side evidence the bulk ResolveStakes uses.
+func (w casinoWallet) ResolveStake(stakeID string) {
+ if stakeID == "" {
+ return
+ }
+ unresolved := w.g.Economy.ResolveStakeByID(economyKey(w.p), stakeID)
+ w.recordResolutionEvidence(unresolved)
+}
+
// recordStakeEvidence adds every currently open stake of the player to the
// file-side resolution evidence list and returns how many IDs were newly
// recorded. Used when the economy journal is unavailable: the open wager
@@ -207,8 +239,25 @@ func (w casinoWallet) recordStakeEvidence() int {
// come back as chips, credits through the durable payout path. The stakes
// are resolved only once the return is durable; on failure the chip return
// is rolled back out of memory and the open records drive recovery instead.
+//
+// Stakes already accounted for by an open settlement payout are NOT
+// refunded: a journaled table settlement includes its hand's stake return
+// and recovery re-applies that owed payout, so refunding the stake here
+// would pay the round twice (the returned credits then arrive on top of
+// the stake). The stakes are resolved; their money arrives with the
+// re-applied settlement.
func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) {
for _, rec := range w.g.Economy.openStakes(economyKey(w.p)) {
+ if resolvedStakeRecorded(w.p, rec.ID) {
+ // Already resolved through file-side evidence: recovery
+ // settles it, never refunds it.
+ continue
+ }
+ if w.g.Economy.stakeCovered(rec) {
+ // The covering settlement is journaled and owed: resolving
+ // the stake defers its return to the payout re-application.
+ continue
+ }
chips += rec.Chips
credits += rec.Credit
}
@@ -227,7 +276,7 @@ func (w casinoWallet) RefundOutstanding() (chips, credits int, err error) {
}
}
if credits > 0 {
- if err := w.payCreditsFlagged(credits, true); err != nil {
+ if err := w.payCreditsFlagged(credits, true, nil); err != nil {
if chips > 0 {
// Roll the chip return back out of memory: the durable
// refund will re-apply both amounts from the open records.
diff --git a/internal/game/casino_refund_regression_test.go b/internal/game/casino_refund_regression_test.go
new file mode 100644
index 0000000..90864e2
--- /dev/null
+++ b/internal/game/casino_refund_regression_test.go
@@ -0,0 +1,164 @@
+package game
+
+// Settlement/refund regressions for the durable-money audit: a settlement
+// whose save failed is journaled as owed money, so a shutdown refund must
+// NOT also return the round's stake — recovery re-applies the owed payout,
+// which already contains the stake's return (table hands) or the round's
+// winnings (slots, where the stake was consumed by playing the round).
+
+import (
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/casino"
+ "thehouseoficarus/internal/player"
+)
+
+// failingSaveDuring swaps the account store for one that cannot save,
+// simulating a character-save failure, and restores it afterwards.
+func failingSaveDuring(g *Game, dir string, fn func()) {
+ realStore := g.AccountStore
+ g.AccountStore = player.NewAccountStore(filepath.Join(dir, "unwritable"))
+ defer func() { g.AccountStore = realStore }()
+ fn()
+}
+
+// TestFailedTableSettlementDoesNotDoubleReturnStake: a won hand whose payout
+// save fails leaves the settlement journaled and owed. A graceful-shutdown
+// refund must not ALSO return the stake; recovery re-applies the payout
+// (which returns the stake inside it) exactly once. 100 - 10 + 20 = 110,
+// never 120.
+func TestFailedTableSettlementDoesNotDoubleReturnStake(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 100
+ w := casinoWallet{g: h.g, p: p}
+ wager, err := w.Wager(10)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ failingSaveDuring(h.g, h.dir, func() {
+ if err := w.PayStakeCover(20, []string{wager.ID}); err == nil {
+ t.Error("expected the settlement save to fail")
+ return
+ }
+ // The shutdown refund path must not return the covered stake.
+ if chips, credits, err := w.RefundOutstanding(); err != nil || chips != 0 || credits != 0 {
+ t.Errorf("shutdown refund returned a covered stake: chips=%d credits=%d err=%v", chips, credits, err)
+ return
+ }
+ })
+ h.g.recoverEconomy()
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if loaded.Credits != 110 {
+ t.Errorf("balance after recovery = %d, want 110 (stake returned exactly once, inside the re-applied payout)", loaded.Credits)
+ }
+ if open := h.g.Economy.mustLoadOpen(t); len(open) != 0 {
+ t.Errorf("records left open after recovery: %+v", open)
+ }
+ })
+}
+
+// TestFailedSlotsPayoutDoesNotDoubleReturnStake: a slot spin's payout is
+// winnings only — the stake is consumed by playing the round. When the
+// payout save fails, the journaled winnings prove the round resolved: the
+// shutdown refund must not return the stake on top of the owed payout.
+func TestFailedSlotsPayoutDoesNotDoubleReturnStake(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 100
+ w := casinoWallet{g: h.g, p: p}
+ if _, err := w.Wager(10); err != nil {
+ t.Error(err)
+ return
+ }
+ failingSaveDuring(h.g, h.dir, func() {
+ // Machine payouts carry no stake linkage (nil covers): the
+ // journaled settlement still resolves the round.
+ if err := w.PayCredits(20); err == nil {
+ t.Error("expected the payout save to fail")
+ return
+ }
+ if chips, credits, err := w.RefundOutstanding(); err != nil || chips != 0 || credits != 0 {
+ t.Errorf("shutdown refund paid a resolved round's stake: chips=%d credits=%d err=%v", chips, credits, err)
+ return
+ }
+ })
+ h.g.recoverEconomy()
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if loaded.Credits != 110 {
+ t.Errorf("balance after recovery = %d, want 110 (stake consumed by the round, winnings paid once)", loaded.Credits)
+ }
+ })
+}
+
+// TestUncoveredStakeStillRefunds: without a journaled settlement, the
+// shutdown refund path keeps returning outstanding stakes (the documented
+// refund-on-shutdown policy) — a regression guard that the coverage skip
+// never over-triggers.
+func TestUncoveredStakeStillRefunds(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 100
+ w := casinoWallet{g: h.g, p: p}
+ if _, err := w.Wager(10); err != nil {
+ t.Error(err)
+ return
+ }
+ chips, credits, err := w.RefundOutstanding()
+ if err != nil || chips != 0 || credits != 10 {
+ t.Errorf("uncovered stake refund: chips=%d credits=%d err=%v", chips, credits, err)
+ return
+ }
+ })
+}
+
+// TestCasinoManagerRefundPathUsesCoverage: the same rule through the live
+// manager path (baccarat settle → RefundAll), exercised with a probe wallet
+// so the settlement save fails mid-round.
+func TestCasinoManagerFailedSettlementRefundIsExact(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 1000
+ h.g.Casino.Join(1, casino.TableConfig{ID: "b", Game: casino.GameBaccarat}, "alice")
+ var totalPaid int
+ w := casinoWallet{g: h.g, p: p}
+ wallet := &fallbackProbeWallet{harness: h, inner: w, onPay: func(credits int, failedCall bool) {
+ totalPaid += credits
+ }}
+ // One bet: a lone player's bet deals and settles immediately. The
+ // wallet fails the first settlement's journal-free save via a
+ // broken SAVE (not journal) so the record stays open and owed.
+ h.g.Casino.Bet(1, "b", "alice", "player", 10, wallet)
+ h.g.emitCasinoEvents(h.g.Casino.RefundAll())
+ h.g.recoverEconomy()
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ // The stake was committed durably (990); RefundAll must not return
+ // it while the settlement payout is owed; recovery re-applies the
+ // payout: 1000 - 10 + totalPaid = final.
+ if want := 1000 - 10 + totalPaid; loaded.Credits != want {
+ t.Errorf("balance after refund+recovery = %d, want %d (wager 10, paid %d)", loaded.Credits, want, totalPaid)
+ }
+ })
+}
diff --git a/internal/game/economy.go b/internal/game/economy.go
index fc1b289..21e7721 100644
--- a/internal/game/economy.go
+++ b/internal/game/economy.go
@@ -89,6 +89,15 @@ type economyRecord struct {
CreditsAfter int `json:"credits_after"`
ChipsBefore int `json:"chips_before"`
ChipsAfter int `json:"chips_after"`
+
+ // Covers lists the stake wager IDs a settlement payout RETURNS (a
+ // table hand pays its stake back inside the payout). Empty means no
+ // explicit linkage: recovery treats such a settlement as resolving
+ // every open stake of its player (the historical behavior, and the
+ // right reading for a slots payout whose stake was consumed by
+ // playing the round). A REFUND payout never covers: its credit part
+ // is the stake return being re-applied, not a new settlement.
+ Covers []string `json:"covers,omitempty"`
}
type economyJournal struct {
@@ -103,12 +112,17 @@ type economyJournal struct {
// ID absent from this set is settled or void and must not be trusted as
// durability evidence.
openPayouts map[string]bool
+ // openPayoutRecs indexes open payout records per player key so a
+ // refund can check which stakes an owed settlement already returns
+ // (stakeCovered). Same ownership discipline as stakes: all mutations
+ // happen on the game owner or during startup recovery.
+ openPayoutRecs map[string][]*economyRecord
}
func newEconomyJournal(dataDir string) *economyJournal {
dir := filepath.Join(dataDir, "economy")
_ = os.MkdirAll(dir, 0700)
- return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord), openPayouts: make(map[string]bool)}
+ return &economyJournal{path: filepath.Join(dir, "journal.jsonl"), stakes: make(map[string][]*economyRecord), openPayouts: make(map[string]bool), openPayoutRecs: make(map[string][]*economyRecord)}
}
func newEconomyRecordID() string {
@@ -156,22 +170,23 @@ func (j *economyJournal) appendRetry(rec economyRecord, attempts int) error {
// start opens a durable record for the in-flight transaction.
func (j *economyJournal) start(kind string, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) {
- return j.startKind(kind, false, p, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter)
+ return j.startKind(kind, false, nil, p, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter)
}
// startRefund opens a refund payout record (stake returned at shutdown or
// eviction; see economyRecord.Refund).
func (j *economyJournal) startRefund(p *player.Player, credits, creditsBefore, creditsAfter int) (*economyRecord, error) {
- return j.startKind("payout", true, p, 0, credits, creditsBefore, creditsAfter, 0, 0)
+ return j.startKind("payout", true, nil, p, 0, credits, creditsBefore, creditsAfter, 0, 0)
}
-func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) {
+func (j *economyJournal) startKind(kind string, refund bool, covers []string, p *player.Player, chips, credits, creditsBefore, creditsAfter, chipsBefore, chipsAfter int) (*economyRecord, error) {
rec := &economyRecord{
ID: newEconomyRecordID(),
Player: p.Name,
PlayerID: p.ID,
Kind: kind,
Refund: refund,
+ Covers: covers,
Tracked: kind == "payout",
Chips: chips,
Credit: credits,
@@ -188,17 +203,35 @@ func (j *economyJournal) startKind(kind string, refund bool, p *player.Player, c
}
if rec.Tracked {
j.openPayouts[rec.ID] = true
+ j.openPayoutRecs[rec.key()] = append(j.openPayoutRecs[rec.key()], rec)
}
return rec, nil
}
+// closePayoutTracking drops rec from the open-payout indexes (its status
+// changed to settled or void).
+func (j *economyJournal) closePayoutTracking(rec *economyRecord) {
+ delete(j.openPayouts, rec.ID)
+ kept := j.openPayoutRecs[rec.key()][:0]
+ for _, open := range j.openPayoutRecs[rec.key()] {
+ if open != rec {
+ kept = append(kept, open)
+ }
+ }
+ if len(kept) == 0 {
+ delete(j.openPayoutRecs, rec.key())
+ } else {
+ j.openPayoutRecs[rec.key()] = kept
+ }
+}
+
// settle marks a record settled (the balance mutation is durable).
// Fire-and-forget: callers whose recovery semantics depend on the marker
// being durable use settleDurable instead.
func (j *economyJournal) settle(rec *economyRecord) {
rec.Status = "settled"
_ = j.append(*rec)
- delete(j.openPayouts, rec.ID)
+ j.closePayoutTracking(rec)
}
// settleDurable marks a record settled and persists the marker with retries,
@@ -211,7 +244,7 @@ func (j *economyJournal) settleDurable(rec *economyRecord) bool {
rec.Status = "open"
return false
}
- delete(j.openPayouts, rec.ID)
+ j.closePayoutTracking(rec)
return true
}
@@ -220,7 +253,7 @@ func (j *economyJournal) settleDurable(rec *economyRecord) bool {
func (j *economyJournal) void(rec *economyRecord) {
rec.Status = "void"
_ = j.append(*rec)
- delete(j.openPayouts, rec.ID)
+ j.closePayoutTracking(rec)
}
// registerStake tracks an outstanding wager record for its player. Wager
@@ -268,6 +301,65 @@ func (j *economyJournal) ResolveStakes(player string) []*economyRecord {
return unresolved
}
+// ResolveStakeByID closes ONE outstanding stake (identified by its wallet
+// handle) with a settled marker: the hand it belongs to was paid or lost,
+// so it must never be refunded even while sibling stakes of the same round
+// remain open. On success the stake leaves the index; a marker whose append
+// cannot be made durable leaves the stake OPEN (the covering settlement's
+// journal record, or file-side resolution evidence, still protects it from
+// being refunded twice).
+func (j *economyJournal) ResolveStakeByID(player, id string) []*economyRecord {
+ recs := j.stakes[player]
+ remaining := make([]*economyRecord, 0, len(recs))
+ var unresolved []*economyRecord
+ for _, rec := range recs {
+ if rec.ID != id {
+ remaining = append(remaining, rec)
+ continue
+ }
+ rec.Status = "settled"
+ if err := j.appendRetry(*rec, 3); err != nil {
+ rec.Status = "open"
+ remaining = append(remaining, rec)
+ unresolved = append(unresolved, rec)
+ }
+ }
+ if len(remaining) == 0 {
+ delete(j.stakes, player)
+ } else {
+ j.stakes[player] = remaining
+ }
+ return unresolved
+}
+
+// stakeCovered reports whether an OPEN payout record already accounts for
+// stake's return. A table settlement lists the stake IDs it covers; a
+// legacy (or slots) settlement without an explicit list is treated as
+// resolving every open stake of its player — a journaled settlement means
+// the round resolved, and its recovery re-application delivers the money.
+// A REFUND payout never covers: its credit part is the stake return being
+// re-applied, not a settlement.
+func (j *economyJournal) stakeCovered(stake *economyRecord) bool {
+ for _, payout := range j.openPayoutRecs[stake.key()] {
+ if payout.Refund {
+ continue
+ }
+ if len(payout.Covers) == 0 || containsString(payout.Covers, stake.ID) {
+ return true
+ }
+ }
+ return false
+}
+
+func containsString(list []string, s string) bool {
+ for _, item := range list {
+ if item == s {
+ return true
+ }
+ }
+ return false
+}
+
// economyKey is the identity a player's journal records are indexed by:
// the stable character ID when known, else the (renameable) name.
func economyKey(p *player.Player) string {
@@ -349,13 +441,16 @@ func (j *economyJournal) reloadStakes() error {
}
j.stakes = make(map[string][]*economyRecord)
j.openPayouts = make(map[string]bool)
+ j.openPayoutRecs = make(map[string][]*economyRecord)
for _, fr := range folded {
if fr.rec.Kind == "wager" && fr.rec.Status == "open" {
rec := fr.rec
j.registerStake(&rec)
}
if fr.rec.Kind == "payout" && fr.rec.Status == "open" {
+ rec := fr.rec
j.openPayouts[fr.rec.ID] = true
+ j.openPayoutRecs[rec.key()] = append(j.openPayoutRecs[rec.key()], &rec)
}
}
return nil
@@ -393,11 +488,19 @@ type foldedRecord struct {
func (j *economyJournal) loadFolded() ([]foldedRecord, error) {
f, err := os.Open(j.path)
if err != nil {
- return nil, nil // no journal yet: nothing to fold
+ if os.IsNotExist(err) {
+ return nil, nil // no journal yet: nothing to fold
+ }
+ // Any other open failure (permissions, symlink loops, I/O errors)
+ // is NOT "an empty healthy journal": callers must not compensate
+ // or compact over a journal whose contents are unknown.
+ return nil, fmt.Errorf("open economy journal: %w", err)
}
defer f.Close()
latest := map[string]*foldedRecord{}
var order []*foldedRecord
+ corruptPending := false
+ corruptSeq := 0
scanner := bufio.NewScanner(f)
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for seq := 0; scanner.Scan(); seq++ {
@@ -407,11 +510,22 @@ func (j *economyJournal) loadFolded() ([]foldedRecord, error) {
}
var rec economyRecord
if err := json.Unmarshal(line, &rec); err != nil {
- // A torn final line (crash mid-append) is not a valid record;
- // skip it. Appends are small and single-line, so only the last
- // line can be torn.
+ // A malformed line is only tolerable as a torn FINAL line (a
+ // crash mid-append; appends are small and single-line, so a
+ // torn write can also merge with a later append onto one
+ // mid-file line). A malformed record followed by ANY other
+ // record means the fold cannot be trusted: surface the
+ // failure instead of skipping the line — compensating or
+ // compacting over a damaged journal could erase owed money.
+ if !corruptPending {
+ corruptPending = true
+ corruptSeq = seq
+ }
continue
}
+ if corruptPending {
+ return nil, fmt.Errorf("economy journal line %d is malformed; later records exist and may be owed money (journal preserved for inspection)", corruptSeq)
+ }
if existing, ok := latest[rec.ID]; ok {
existing.rec = rec
existing.seq = seq
@@ -447,6 +561,7 @@ func (j *economyJournal) compact() {
tmp := j.path + ".compact"
f, err := os.OpenFile(tmp, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
+ fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err)
return
}
defer os.Remove(tmp)
@@ -457,7 +572,11 @@ func (j *economyJournal) compact() {
if err != nil {
continue
}
- w.Write(data)
+ if _, err := w.Write(data); err != nil {
+ f.Close()
+ fmt.Fprintf(os.Stderr, "economy compaction aborted: %v (journal preserved for inspection)\n", err)
+ return
+ }
w.WriteByte('\n')
}
if err := w.Flush(); err != nil {
@@ -743,9 +862,13 @@ func (g *Game) recoverEconomy() {
fmt.Fprintf(os.Stderr, "economy recovery: load %s: character not found\n", rec.Player)
return nil
}
- // laterPayoutEvidence reports whether any non-void payout record was
- // journaled for the player after seq — evidence the round resolved and
- // its settlement was recorded.
+ // stakeEvidence reports whether any non-void payout record was
+ // journaled for this stake after seq — evidence the round resolved and
+ // its settlement was recorded. A settlement that lists explicit stake
+ // IDs (a table hand) only proves THIS stake when it covers it; a
+ // legacy (or slots) payout without a list proves the round resolved
+ // (the historical, and slots-correct, reading: the stake was consumed
+ // by playing the round).
//
// settlement separately reports whether a SETTLEMENT payout (not a
// refund) was journaled later: a settlement — even a partial one like
@@ -756,17 +879,21 @@ func (g *Game) recoverEconomy() {
// instead marks a refund in progress: its credit part is re-applied by
// phase 1 and the chip part (if the debit was durable) still comes
// back in chips.
- laterPayoutEvidence := func(rec *economyRecord, seq int) (resolved bool, settlement bool) {
+ stakeEvidence := func(rec *economyRecord, seq int) (evidence bool, settlement bool) {
for _, fr := range folded {
- if fr.rec.Kind == "payout" && fr.rec.Status != "void" && fr.seq > seq &&
- payoutIdentifies(fr.rec, *rec) {
- resolved = true
- if !fr.rec.Refund {
- settlement = true
- }
+ if fr.rec.Kind != "payout" || fr.rec.Status == "void" || fr.seq <= seq ||
+ !payoutIdentifies(fr.rec, *rec) {
+ continue
+ }
+ if len(fr.rec.Covers) > 0 && !containsString(fr.rec.Covers, rec.ID) {
+ continue
+ }
+ evidence = true
+ if !fr.rec.Refund {
+ settlement = true
}
}
- return resolved, settlement
+ return evidence, settlement
}
compensated := 0
@@ -955,7 +1082,7 @@ func (g *Game) recoverEconomy() {
compensated++
continue
}
- resolvedByPayout, resolvedBySettlement := laterPayoutEvidence(rec, folded[openWagers[k]].seq)
+ resolvedByPayout, resolvedBySettlement := stakeEvidence(rec, folded[openWagers[k]].seq)
// Checkpoint comparisons use the DURABLE state, captured before any
// in-memory chip restoration below.
creditsNow, chipsNow := p.Credits, countChips(p)
diff --git a/internal/game/journal_damage_regression_test.go b/internal/game/journal_damage_regression_test.go
new file mode 100644
index 0000000..d8577fe
--- /dev/null
+++ b/internal/game/journal_damage_regression_test.go
@@ -0,0 +1,123 @@
+package game
+
+// Journal-damage regressions: a malformed record followed by other records
+// must abort the fold (the journal is preserved for inspection — records
+// beyond the damage may be owed money), an open failure is never "an empty
+// healthy journal", and compaction can never run over an untrusted fold.
+
+import (
+ "os"
+ "testing"
+)
+
+// TestMalformedJournalRecordAbortsFold: a malformed line followed by a valid
+// record makes loadFolded fail — skipping the line and continuing would let
+// compensation and compaction run over records that may owe money.
+func TestMalformedJournalRecordAbortsFold(t *testing.T) {
+ j := newEconomyJournal(t.TempDir())
+ original := []byte("{\"id\":\"damaged\",\"kind\":\"payout\"\n{\"id\":\"valid\",\"kind\":\"payout\",\"status\":\"open\",\"credit\":20}\n")
+ if err := os.WriteFile(j.path, original, 0600); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := j.loadFolded(); err == nil {
+ t.Fatal("malformed mid-file record silently accepted")
+ }
+ // Compaction must abort and preserve the journal verbatim.
+ j.compact()
+ after, err := os.ReadFile(j.path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(after) != string(original) {
+ t.Errorf("compaction rewrote a damaged journal: %s", after)
+ }
+}
+
+// TestTornFinalJournalLineIsTolerated: a torn LAST line (crash mid-append)
+// is skipped — every valid record before it still folds.
+func TestTornFinalJournalLineIsTolerated(t *testing.T) {
+ j := newEconomyJournal(t.TempDir())
+ data := []byte("{\"id\":\"a\",\"kind\":\"wager\",\"status\":\"open\"}\n{\"id\":\"b\",\"kind\":\"payo")
+ if err := os.WriteFile(j.path, data, 0600); err != nil {
+ t.Fatal(err)
+ }
+ folded, err := j.loadFolded()
+ if err != nil {
+ t.Fatalf("torn final line rejected a healthy fold: %v", err)
+ }
+ if len(folded) != 1 || folded[0].rec.ID != "a" {
+ t.Fatalf("unexpected fold: %+v", folded)
+ }
+}
+
+// TestJournalOpenErrorIsNotAnEmptyFold: an open failure (permissions, a
+// symlink loop) must be an error, never "no journal" — recovery would
+// otherwise treat owed money as never-charged.
+func TestJournalOpenErrorIsNotAnEmptyFold(t *testing.T) {
+ j := newEconomyJournal(t.TempDir())
+ if err := os.Symlink(j.path, j.path); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := j.loadFolded(); err == nil {
+ t.Error("ELOOP open failure reported as an empty healthy journal")
+ }
+}
+
+// TestOpenFailureAbortsRecovery: recoverEconomy over a journal that cannot
+// be fully read (malformed record with later records present) must abort
+// without paying owed money or compacting.
+func TestOpenFailureAbortsRecovery(t *testing.T) {
+ h := newHarness(t)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.Credits = 100
+ if err := h.g.AccountStore.SaveCharacter(p); err != nil {
+ t.Error(err)
+ return
+ }
+ // Corrupt the journal mid-file (a damaged record followed by an
+ // owed payout — appends after a torn merged line), then attempt
+ // recovery.
+ f, err := os.OpenFile(h.g.Economy.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0600)
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if _, werr := f.WriteString("{\"id\":\"damaged\",\"kind\":\"payout\"\n"); werr != nil {
+ t.Error(werr)
+ }
+ f.Close()
+ if _, err := h.g.Economy.start("payout", p, 0, 20, 100, 120, 0, 0); err != nil {
+ t.Errorf("owed payout could not be journaled: %v", err)
+ return
+ }
+ h.g.recoverEconomy()
+ // The damaged fold aborted recovery: no compensation ran and no
+ // compaction erased the records.
+ loaded, err := h.g.AccountStore.LoadCharacter("alice")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if loaded.Credits != 100 {
+ t.Errorf("recovery compensated over a damaged journal: credits=%d, want 100 (preserved for inspection)", loaded.Credits)
+ }
+ data, err := os.ReadFile(h.g.Economy.path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !containsBytes(data, "kind\":\"payout\"") {
+ t.Error("recovery compacted a damaged journal, erasing records preserved for inspection")
+ }
+ })
+}
+
+func containsBytes(data []byte, s string) bool {
+ for i := 0; i+len(s) <= len(data); i++ {
+ if string(data[i:i+len(s)]) == s {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/game/journal_failure_regression_test.go b/internal/game/journal_failure_regression_test.go
index 683b996..043afeb 100644
--- a/internal/game/journal_failure_regression_test.go
+++ b/internal/game/journal_failure_regression_test.go
@@ -99,6 +99,21 @@ func (w *fallbackProbeWallet) PayCredits(amount int) error {
}
func (w *fallbackProbeWallet) ResolveStakes() { w.inner.ResolveStakes() }
+
+func (w *fallbackProbeWallet) PayStakeCover(amount int, stakeIDs []string) error {
+ if w.broke {
+ w.onPay(amount, false)
+ return w.inner.PayStakeCover(amount, stakeIDs)
+ }
+ w.broke = true
+ w.onPay(amount, true)
+ failJournalDuring(w.harness, func() {
+ _ = w.inner.PayStakeCover(amount, stakeIDs)
+ })
+ return nil
+}
+
+func (w *fallbackProbeWallet) ResolveStake(stakeID string) { w.inner.ResolveStake(stakeID) }
func (w *fallbackProbeWallet) RefundOutstanding() (int, int, error) {
return w.inner.RefundOutstanding()
}
diff --git a/internal/game/production_engine.go b/internal/game/production_engine.go
index 9643483..2cd9b1d 100644
--- a/internal/game/production_engine.go
+++ b/internal/game/production_engine.go
@@ -215,36 +215,39 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
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 false
+ }
+
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, item)
- placed := false
- outDef, _ := g.ItemStore.Load(item.ID)
- if outDef != nil && outDef.Stackable {
- for i := 0; i < 28; i++ {
- slot := p.InvSlot(i)
- if slot != nil && slot.ItemID == item.ID {
- consumeIngredients(craft, p.CountItem, p.RemoveItem)
- slot.Quantity += outputQty
- placed = true
- break
- }
- }
+ 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 false
}
- if !placed {
- consumeIngredients(craft, p.CountItem, p.RemoveItem)
- freeSlot := p.FirstFreeSlot()
- if freeSlot == -1 {
- sess.WriteLine("Your inventory is too full!")
- g.cancelAction(p)
- return false
- }
- p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: item.ID, Quantity: outputQty})
+ 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 false
}
for _, bp := range byproducts {
@@ -272,7 +275,10 @@ func (g *Game) advanceProduction(sess *net.Session, p *player.Player) bool {
}
sess.WriteLine(msg)
} else {
- consumeIngredients(craft, p.CountItem, p.RemoveItem)
+ if !consumeIngredients(craft, p.CountItem, p.RemoveItem) {
+ g.cancelAction(p)
+ return false
+ }
if craft.Fail != "" {
freeSlot := p.FirstFreeSlot()
@@ -317,20 +323,26 @@ func (g *Game) collectByproducts(p *player.Player, item *item.ItemDef) []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 _, e := range craft.Ingredients {
+ for i := range craft.Ingredients {
+ e := &craft.Ingredients[i]
if len(e.Byproducts) == 0 {
continue
}
- // The byproduct must match the alternative that will actually be
- // consumed (the same craftIngredientSource resolution
- // consumeIngredients uses), 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.
- id, _ := craftIngredientSource(&e, p.CountItem)
- for i, alt := range e.Items {
- if alt == id && i < len(e.Byproducts) && e.Byproducts[i] != "" {
- byproducts = append(byproducts, e.Byproducts[i])
+ 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
}
}
diff --git a/internal/game/production_index.go b/internal/game/production_index.go
index 8c0207c..ffacd8a 100644
--- a/internal/game/production_index.go
+++ b/internal/game/production_index.go
@@ -4,6 +4,7 @@ import (
"sync"
"thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/player"
)
type CraftIndex struct {
@@ -208,43 +209,103 @@ func craftIngredientSource(e *item.IngredientEntry, countItem func(string) int)
}
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 false
+ return nil
}
+ reserved := make(map[string]int, len(c.Ingredients))
+ plan := make([]consumption, 0, len(c.Ingredients))
for i := range c.Ingredients {
- if id, _ := craftIngredientSource(&c.Ingredients[i], countItem); id == "" {
- return false
+ 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 true
+ return plan
}
// consumeIngredients removes one batch of every ingredient entry. Each
-// entry consumes the same alternative the availability checks verify (the
-// first one covering the FULL quantity), and consumption is two-pass:
-// nothing is removed unless every entry resolves, so a craft can never
-// partially consume an alternative and silently keep the rest. 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.
+// 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 {
- if c == nil {
- return true
+ plan := planIngredients(c, countItem)
+ if plan == nil {
+ return false
}
- type consumption struct {
- id string
- qty int
+ for _, s := range plan {
+ removeItem(s.id, s.qty)
}
- plan := make([]consumption, 0, len(c.Ingredients))
- for i := range c.Ingredients {
- id, qty := craftIngredientSource(&c.Ingredients[i], countItem)
- if id == "" {
- return false
+ return true
+}
+
+// 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 {
+ for i := 0; i < 28; i++ {
+ if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID {
+ return true
+ }
}
- plan = append(plan, consumption{id: id, qty: qty})
}
- for _, s := range plan {
- removeItem(s.id, s.qty)
+ 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 {
+ if outDef != nil && outDef.Stackable {
+ for i := 0; i < 28; i++ {
+ if slot := p.InvSlot(i); slot != nil && slot.ItemID == itemID {
+ slot.Quantity += outputQty
+ return true
+ }
+ }
+ }
+ freeSlot := p.FirstFreeSlot()
+ if freeSlot == -1 {
+ return false
}
+ p.SetInvSlot(freeSlot, &player.InventorySlot{ItemID: itemID, Quantity: outputQty})
return true
}
diff --git a/internal/game/production_output_regression_test.go b/internal/game/production_output_regression_test.go
new file mode 100644
index 0000000..cee028d
--- /dev/null
+++ b/internal/game/production_output_regression_test.go
@@ -0,0 +1,186 @@
+package game
+
+// Crafting-output regressions: a full inventory must never consume
+// materials without producing the output, and recipes whose entries share
+// the same item must reserve aggregate quantities (two one-ore entries need
+// two ore), never double-count one stack.
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "thehouseoficarus/internal/item"
+ "thehouseoficarus/internal/net"
+ "thehouseoficarus/internal/player"
+)
+
+const headlessArrowYAML = `id: headless_arrow
+name: headless arrow
+stackable: true
+value: 1
+craft:
+ - ingredients:
+ - items:
+ - arrow_shaft
+ quantity: 15
+ - items:
+ - feather
+ quantity: 15
+ level: 1
+ output_qty: 15
+ type: fletching
+ ticks_per_cycle: 2
+ xp: 1
+`
+
+func seedArrowWorld(t *testing.T, dir string) {
+ t.Helper()
+ seedWorld(t, dir)
+ defs := map[string]string{
+ "arrow_shaft": "id: arrow_shaft\nname: arrow shaft\nstackable: true\nvalue: 1\n",
+ "feather": "id: feather\nname: feather\nstackable: true\nvalue: 1\n",
+ "headless_arrow": headlessArrowYAML,
+ }
+ for id, data := range defs {
+ path := filepath.Join(dir, "items", id+".yaml")
+ if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(path, []byte(data), 0600); err != nil {
+ t.Fatal(err)
+ }
+ }
+}
+
+// TestFullInventoryFletchPreservesMaterials: a full inventory must refuse
+// the craft ("too full") WITHOUT consuming the ingredients — before the
+// fix, the 15-shaft/15-feather batch was consumed and the output silently
+// dropped.
+func TestFullInventoryFletchPreservesMaterials(t *testing.T) {
+ dir := t.TempDir()
+ seedArrowWorld(t, dir)
+ h := harnessFromDir(t, dir)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "arrow_shaft", Quantity: 30})
+ p.SetInvSlot(1, &player.InventorySlot{ItemID: "feather", Quantity: 30})
+ for i := 2; i < 28; i++ {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1})
+ }
+ def, err := h.g.ItemStore.Load("headless_arrow")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if !h.g.canDoFletchItem(p, def) {
+ t.Error("recipe unexpectedly unavailable")
+ return
+ }
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+ h.g.startFletchAction(sess, p, def, 1)
+ // The timed fletch advances: phase 0 announces, phase 1 crafts.
+ h.g.advanceFletch(sess, p)
+ h.g.advanceFletch(sess, p)
+ if p.CountItem("arrow_shaft") != 30 || p.CountItem("feather") != 30 {
+ t.Errorf("materials consumed with no output: shafts=%d feathers=%d; want 30 each",
+ p.CountItem("arrow_shaft"), p.CountItem("feather"))
+ }
+ if p.CountItem("headless_arrow") != 0 {
+ t.Errorf("output produced into a full inventory: %d", p.CountItem("headless_arrow"))
+ }
+ })
+}
+
+// TestFullInventoryStackMergeStillCrafts: a full inventory may still craft
+// when the output merges into an existing stack.
+func TestFullInventoryStackMergeStillCrafts(t *testing.T) {
+ dir := t.TempDir()
+ seedArrowWorld(t, dir)
+ h := harnessFromDir(t, dir)
+ h.seedAccount("alice")
+ p := h.seedCharacter("alice", "alice")
+ h.g.RunOnOwner(func() {
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "arrow_shaft", Quantity: 30})
+ p.SetInvSlot(1, &player.InventorySlot{ItemID: "feather", Quantity: 30})
+ p.SetInvSlot(2, &player.InventorySlot{ItemID: "headless_arrow", Quantity: 5})
+ for i := 3; i < 28; i++ {
+ p.SetInvSlot(i, &player.InventorySlot{ItemID: "filler", Quantity: 1})
+ }
+ def, err := h.g.ItemStore.Load("headless_arrow")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ sess := &net.Session{Conn: newChanConn(), Player: p}
+ sess.SetState(net.StateGame)
+ h.g.startFletchAction(sess, p, def, 1)
+ h.g.advanceFletch(sess, p)
+ h.g.advanceFletch(sess, p)
+ if got := p.CountItem("headless_arrow"); got != 20 {
+ t.Errorf("headless_arrow = %d, want 20 (5 existing + 15 new, merged)", got)
+ }
+ if p.CountItem("arrow_shaft") != 15 || p.CountItem("feather") != 15 {
+ t.Errorf("ingredients not consumed: shafts=%d feathers=%d", p.CountItem("arrow_shaft"), p.CountItem("feather"))
+ }
+ })
+}
+
+// TestOverlappingRecipeReservesAggregateStock: two ingredient entries
+// listing the same item cannot both consume one unit of it.
+func TestOverlappingRecipeReservesAggregateStock(t *testing.T) {
+ p := player.New("alice")
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "ore", Quantity: 1})
+ c := &item.CraftDef{Ingredients: []item.IngredientEntry{
+ {Items: []string{"ore"}, Quantity: 1},
+ {Items: []string{"ore"}, Quantity: 1},
+ }}
+ if craftHasAllItemsQty(c, p.CountItem) {
+ t.Error("one ore accepted for two one-ore entries")
+ }
+ if consumeIngredients(c, p.CountItem, p.RemoveItem) {
+ t.Error("consumption reports success despite insufficient aggregate stock")
+ }
+ if p.CountItem("ore") != 1 {
+ t.Error("a refused recipe removed partial materials")
+ }
+}
+
+// TestOverlappingRecipeReservesAcrossEntries: reservations apply across
+// differing alternative lists — two one-water entries need TWO water-like
+// items, and the plan consumes distinct ones, never one twice.
+func TestOverlappingRecipeReservesAcrossEntries(t *testing.T) {
+ // Only one water-carrying item: the second entry cannot be satisfied.
+ p := player.New("alice")
+ p.SetInvSlot(0, &player.InventorySlot{ItemID: "clay", Quantity: 1})
+ p.SetInvSlot(1, &player.InventorySlot{ItemID: "bucket_of_water", Quantity: 1})
+ c := &item.CraftDef{Ingredients: []item.IngredientEntry{
+ {Items: []string{"clay"}, Quantity: 1},
+ {Items: []string{"water", "bucket_of_water"}, Quantity: 1},
+ {Items: []string{"bucket_of_water", "water"}, Quantity: 1},
+ }}
+ if planIngredients(c, p.CountItem) != nil {
+ t.Error("one water unit accepted for two one-water entries")
+ }
+ if p.CountItem("clay") != 1 || p.CountItem("bucket_of_water") != 1 {
+ t.Error("availability planning mutated inventory")
+ }
+
+ // Both water items present: the plan consumes each exactly once.
+ p.SetInvSlot(2, &player.InventorySlot{ItemID: "water", Quantity: 1})
+ plan := planIngredients(c, p.CountItem)
+ if plan == nil {
+ t.Fatal("clay + bucket + water must satisfy the recipe")
+ }
+ chosen := map[string]int{}
+ for _, s := range plan {
+ chosen[s.id]++
+ }
+ for id, want := range map[string]int{"clay": 1, "bucket_of_water": 1, "water": 1} {
+ if chosen[id] != want {
+ t.Errorf("plan chose %s %d times, want %d (plan=%+v)", id, chosen[id], want, plan)
+ }
+ }
+}