Skip to content

fix(status): stop generic decay from racing the web struggle branch - #166

Merged
mparrett merged 1 commit into
mainfrom
fix/web-trample
Sep 21, 2026
Merged

mparrett merged 1 commit into
mainfrom
fix/web-trample

Conversation

@mparrett

@mparrett mparrett commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #161. Full walkthrough of the mechanism is in the issue comment; short version: :webbed ttl was decremented twice per turn — once by the movement struggle branches (the only place that tramples, at ttl ≤ 1) and once by the generic status tick (which removes an expired status silently). The status always expired inside the tick, so the trample branch was unreachable. #71's ttl reduction (3 to 2) exposed it on the player path; the monster path (tick before action in creature-turn) had never trampled since webs landed.

Note: Temporarily changed ash from . to , in order to disambiguate and verify the changes. Not included in this PR, but might be worth discussing when we get back to gameplay aesethetics.

image

Approach

Statuses can declare :no-decay in status-defs, and :webbed does: the generic tick skips it, and the struggle branches become the sole owner of the ttl. One mechanism fixes both paths, and the ttl now means what the #71 balance knob assumed: the number of struggles to break free. The alternative — trampling on expiry inside the tick — would have coupled status.lg to fire.lg and kept two owners of the same counter.

Behavior note (balance-visible): a webbed creature that never attempts a move — say, attacking an adjacent target — now stays webbed until it does, instead of timing out of the web by standing still. That reads as correct to me; easy to revisit if it plays badly.

Tests

New xsofy/test/status_test.lg: the generic tick leaves :webbed untouched while :confused still decays, and a player struggling free ends on ash, not an intact web. Suite is green (299 tests, 2777 assertions); the new assertions fail on main.

🤖 Generated with Claude Code

…161)

Two systems decremented :webbed each turn: the movement struggle
branches (world/player-move, ai/try-move), which trample the web on the
last struggle, and the generic status tick, which removes an expired
status silently. The ttl drained twice per turn and always expired
inside the tick, so the trample branch never ran and every web survived
its captive breaking free.

PR #71 exposed this on the player path by softening the web ttl 3 -> 2
(at 3 the struggle branch happened to win the race); the monster path
(tick before action in creature-turn) has never trampled since webs
landed in 8feaa1c.

Statuses can now declare :no-decay — the generic tick leaves their ttl
to the owning system. :webbed's ttl becomes what the balance knob
always looked like: the number of struggles to break free.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mparrett
mparrett requested a review from nooga July 21, 2026 15:12
@mparrett mparrett added the deploy-preview Publish a gh-pages WASM preview for this PR label Jul 21, 2026
@github-actions

github-actions Bot commented Jul 21, 2026 •

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-09-21 23:52 UTC

@mparrett

mparrett commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

FYI — initia self-review notes.

Root cause confirmed. :webbed ttl was decremented by two independent systems — the movement struggle branches (world/player-move, ai/try-move) and the generic per-turn status tick — so it drained twice a turn and expired inside the tick, which removes the status silently and never tramples the web. Marking :webbed :no-decay hands sole ownership to the struggle branches, where the trample lives. Right fix, narrow scope.

Validated. Full suite green (299 tests / 2777 assertions), including replay-test, so determinism holds. Differential check: with main's pre-fix status.lg swapped back in, both new tests fail (webbed drains early, web left un-trampled) and pass with the fix — real guards.

One behavior change to decide on: breaking free now requires a move attempt. Resting/waiting no longer decays the web, and a webbed monster that only sleeps or attacks in place stays webbed indefinitely. This matches the documented intent (fire.lg:136 — "after N struggles the web is trampled"), so waiting-out was probably the accident. Open question: should the web still tick down when you wait?

Minor: :no-decay short-circuits the whole status entry, including :dmg. Harmless for :webbed (no damage), but a future damaging :no-decay status wouldn't tick — worth a comment at the guard.

Not a recent regression: the double-decrement dates to the original web commit (8feaa1c), not a later break.

@mparrett

mparrett commented Jul 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Tested web struggle and it behaved predictablly.

image

@mparrett

Copy link
Copy Markdown
Collaborator Author

Tested web struggle and it behaved predictablly.

image

not seen (well) in this image: ash.
Added a new screenshot to the PR body that shows it, unambiguously.

@mparrett

Copy link
Copy Markdown
Collaborator Author

Review context for this and the other five ready PRs, with suggested order and merge-order notes: #182

@nnunley nnunley left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry for the delay. I think this is probably the wrong solution for this problem. I think that there should be a consolidation pass that's idempotent for the effects of actions within the turn (order of application should preferably not matter within the tick). The web decay's tick should be calculated as a forward projected time in rounds/ticks

Note that a player turn should take some multiple > 1 worth of ticks (like maybe 1000) to allow spells like haste to actually do something interesting. Trampling should have at most one entity affecting the object, and should have a handler for not just webs, but grasses, water, etc.

I'm willing to let this pass, but there feels like some hackery going on to get things working.

Comment thread xsofy/test/status_test.lg
Comment thread xsofy/test/status_test.lg
@mparrett

Copy link
Copy Markdown
Collaborator Author

Sorry for the delay. I think this is probably the wrong solution for this problem. I think that there should be a consolidation pass that's idempotent for the effects of actions within the turn (order of application should preferably not matter within the tick). The web decay's tick should be calculated as a forward projected time in rounds/ticks

Note that a player turn should take some multiple > 1 worth of ticks (like maybe 1000) to allow spells like haste to actually do something interesting. Trampling should have at most one entity affecting the object, and should have a handler for not just webs, but grasses, water, etc.

I'm willing to let this pass, but there feels like some hackery going on to get things working.

Thanks for the great feedback. Let me take a fresh look and see if it makes sense to move this forward or revise.

@mparrett

mparrett commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

@nnunley Thanks again for the review. Here's the longer response after looking through the code with Claude.

I tested the "mutating state multiple times" half of your point and it doesn't hold for the per-turn counters: just two places outside status.lg write a status ttl, and both are the :webbed struggle branches relevant to this PR:

  1. the player branch in player-move
  2. the creature branch in ai.lg

Every other status goes through apply-status / remove-status (11 sites) and the single tick-entity-statuses. The surrounding turn machinery is clean: update-world dispatches one action per call, every add-ticks :player site sits in a mutually exclusive branch, tick-vfx has one caller, and step-fire, tick-pending-allies, burning-entities-ignite, and drift-water-items appear twice in world.lg but only because they are duplicated across the two arms of (if *perf-detail* ...), a bench-timing split with the same sub-phase order. So :webbed is the only counter with two owners.

I didn't test the order-dependence half — that's a different property, and single-application doesn't imply order-independence.

On the tick model

I think you're right: add-ticks scales :ticks and regen-ticks by cost, then unconditionally calls tick-entity-statuses. As a result, status ttl decrements once per action and is never scaled by what that action cost. Swimming costs (* move-cost 2) and burns one ttl, the same as a normal step; a speed-modified creature decays at a rate decoupled from the ticks it actually spends. ttl is documented as "turns remaining" and is really "actions remaining". Your intuition about a forward-projected expiry makes sense in this context, and doesn't depend on the web bug.

It's also what stands between us and your 1000-ticks-per-turn idea: haste can't mean much while decay counts actions rather than ticks.

And we have this asymmetry: player ticks statuses inside add-ticks, creatures tick at the top of creature-turn before acting. One concept, two homes, which is how a second owner got in.

Projected time would be the wrong fit for :webbed. Its ttl is not a status effect timer, but a counter of struggles to break free, per #71. The behavior note in the description says: a webbed creature that never attempts a move stays webbed. Convert it to projected ticks and a web times out while you stand still. That's a different rule, and we should consider it, but it's deliberately out of scope here. A consolidation pass likely makes sense, but it has to model counters as well as timers, or :webbed becomes a timer on the way through.

So unless the above changes your mind, I'll merge this as the narrow fix, since #161 is a live regression and the new assertions fail on main. I'll then open an issue for the consolidation pass carrying the per-action-versus-per-tick finding above, which has a larger blast radius and needs some design thinking — and I'll fold your trample point into it, since "at most one entity affecting the object" and a handler covering grasses and water as well as webs is the same shape of problem.

That said, if you prefer :no-decay isn't in the tree at all, I'm happy to close this in favor of the refactor, which will then take responsibility for addressing #161.

@mparrett

mparrett commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

One amendment to my own survey. The order-dependence half is already tracked: #187 is that case. process-creatures (ai.lg:424) takes creature acting order straight from (vals (:entities world)) with no sort, and since every creature action advances the det seed chain, acting order is part of the determinism contract. find-target already guards the same dependency by sorting on (juxt dist-to (comp str :id)); process-creatures doesn't.

I filed that on 2026-08-20, before your review, so your instinct had more behind it than my "I didn't test that half" line suggested. It goes into the consolidation-pass issue as the order half.

@nooga nooga left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed via automated multi-angle analysis (line-by-line, removed-behavior, cross-file trace, cleanup, efficiency, altitude, CLAUDE.md conventions) plus a full test-suite run.

Verdict: approve. The fix is correct — :no-decay cleanly exempts :webbed from the generic per-turn tick so it's only cleared by the struggle-resolution branches, closing the double-decrement race. Full suite passes (318 tests / 2825 assertions), including the two new regression tests.

Two non-blocking notes for a follow-up, not this PR:

  1. xsofy/status.lg:63 — tick-entity-statuses looks up status-defs twice per status per tick (once for the :no-decay check, again a couple lines later as def_). Minor hot-path inefficiency; hoisting the lookup above the if would remove the redundant map lookup.
  2. xsofy/status.lg:24 — :webbed now has no time-based expiry at all; it only clears via the struggle branches, which only fire on a move attempt. An entity that never moves again (docile AI, or a player who only attacks/waits) stays webbed indefinitely. This is likely intended ("only struggling frees you"), but it's a real behavior change from before and isn't called out in the docstring/comment — worth a one-line note.

🤖 Generated with Claude Code

@nooga
nooga dismissed nnunley’s stale review September 20, 2026 19:42

Dismissing per maintainer decision: this fixes a live, single-cause regression (#161) with a narrow, well-tested mechanism. mparrett's investigation confirmed :webbed is the only status with two ttl owners, so this isn't the systemic pattern the requested consolidation pass would need to address. The legitimate parts of the feedback (tick-scaled decay for haste, generalized trampling beyond webs, order-dependence) are being tracked in a follow-up issue instead of blocking this fix. You said you were willing to let this pass — merging on that basis.

@mparrett
mparrett merged commit 7d127a2 into main Sep 21, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deploy-preview Publish a gh-pages WASM preview for this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spider webs no longer trampled

3 participants