Split Deep challenge stakes into success and failure fields

The Deep now states what happens on success and on failure as two
separate fields when calling a Challenge, instead of one free-text
stakes blob. ChallengePanel shows them as " On success" / " On
failure"; the event log lists both.

- Challenge.stakes_success / stakes_failure (+ migration); the old
  single `stakes` column is kept (vestigial) so in-scene rollback
  snapshots taken before the upgrade still deserialize, and ChallengePanel
  falls back to it for any pre-existing challenge.
- create_challenge / route / DeepControlPanel updated; tests adjusted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 09:28:38 -07:00
parent 5a8919d967
commit 39dc07d6b6
8 changed files with 77 additions and 24 deletions

16
TODO.md
View File

@@ -12,7 +12,7 @@
- [x] On the screen after pirat rank up voting, it should display the winner
- [ ] When Deep issues a challenge, stakes should be two fields, success and failure
- [x] When Deep issues a challenge, stakes should be two fields, success and failure
- [ ] Captain should not be autoassigned. That's something the crew has to earn.
@@ -24,6 +24,12 @@
- [ ] In between scenes, there really only needs to be one panel with the roster and voting status. There doesn't need to be a whole extra panel to inform players that the deep will later get to refresh their hands.
## UI Polish
- [ ] **Make card tooltips more graphical** Rather than just being an HTML title attribute, they should be a nicely formatted on-hover UI element. There should also be tooltips in the challenge/obstacle box/resting deep phase card displays (basically, anywhere a card is rendered it should have a tooltip), and these should describe the card, not the event of it being played, since that's covered by the event log.
- [ ] **Make the color of challenges more clear**. Right now there are all sorts of highlights around the challenge box, and it's not clear if red or black is the "good" color to play on it. Also, double check the rulebook about whether the color of the challenge is based on the original card or the latest card played.
## Words Words Words
- [ ] **Example Play formatting.** The Example Play section of the Rules page could use a bit more formatting for legibility. Make diagrams of the card state at each step rather than just a text listing.
@@ -41,14 +47,6 @@
- [ ] **Scene dashboard design refresh.** There should be a column of bubbles/buttons to the left of the obstacle list panel. These represent the crew. You can click a Pi-Rat to pop out their character sheet. If the other player is a Pi-Rat in the scene, there's the "challenge them" UI, which no longer needs the "Challenge whom?" since it's tied to a particular character sheet. Finally, there's the personal objective list. There's also an objectives button (at the top level of the hierarchy) which shows a popup for crew objectives. This entirely replaces the character sheet panel on the current dashboard, as well as the roster at the top of the obstacle list. The captain has a steering wheel emoji next to them. The display should also show current hand size for all players.
- [ ] **Make card tooltips more graphical** Rather than just being an HTML title attribute, they should be a nicely formatted pop-up UI element. There should also be tooltips in the challenge/obstacle box card displays, and these should describe the card, not the event of it being played, since that's covered by the event log.
- [ ] **Add card tooltips to more things**. They need to be in the challenge UI and resting deep phase.
- [ ] **Make the color of challenges more clear**. Right now there are all sorts of highlights around the challenge box, and it's not clear if red or black is the "good" color to play on it. Also, double check the rulebook about whether the color of the challenge is based on the original card or the latest card played.
- [ ] Interaction during a challenge should be promoted to the top of the UI
- [ ] Event log should be a top level panel, not an overlay.

View File

@@ -78,7 +78,13 @@
</div>
{/if}
</div>
{#if ch.stakes}
{#if ch.stakes_success}
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>✅ On success:</strong> {ch.stakes_success}</p>
{/if}
{#if ch.stakes_failure}
<p class="info-text" style="margin: 0.25rem 0 0 0;"><strong>❌ On failure:</strong> {ch.stakes_failure}</p>
{/if}
{#if ch.stakes && !ch.stakes_success && !ch.stakes_failure}
<p class="info-text" style="margin: 0.5rem 0 0 0;"><strong>Stakes:</strong> {ch.stakes}</p>
{/if}
{#if ch.challenge_type === 'pvp'}

View File

@@ -6,7 +6,8 @@
let error = '';
let challengeTargetId = '';
let challengeStakes = '';
let stakesSuccess = '';
let stakesFailure = '';
let selectedObstacles = {};
let captainSelectId = '';
@@ -21,10 +22,12 @@
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/challenge/create`, 'POST', {
target_player_id: challengeTargetId,
obstacle_ids: JSON.stringify(ids),
stakes: challengeStakes
stakes_success: stakesSuccess,
stakes_failure: stakesFailure
});
challengeTargetId = '';
challengeStakes = '';
stakesSuccess = '';
stakesFailure = '';
selectedObstacles = {};
} catch(e) {
error = e.message;
@@ -88,7 +91,8 @@
</label>
{/each}
</div>
<input type="text" class="input-field" placeholder="Stakes (optional): what happens on success/failure?" bind:value={challengeStakes} style="width: 100%; margin-bottom: 0.5rem;">
<input type="text" class="input-field" placeholder="On success (optional): what they win…" bind:value={stakesSuccess} style="width: 100%; margin-bottom: 0.5rem;">
<input type="text" class="input-field" placeholder="On failure (optional): what it costs them…" bind:value={stakesFailure} style="width: 100%; margin-bottom: 0.5rem;">
<button class="btn btn-primary btn-full" on:click={createChallenge}
disabled={!challengeTargetId || !Object.values(selectedObstacles).some(v => v)}>
Call Challenge

View File

@@ -36,7 +36,8 @@ def create_challenge(
deep_player_id: str,
target_player_id: str,
obstacle_ids: List[str],
stakes: str = ""
stakes_success: str = "",
stakes_failure: str = ""
) -> Tuple[bool, str]:
"""A Deep Player calls for a Challenge, applying one or more Obstacles from the list to a Pi-Rat."""
game = get_game(db, game_id)
@@ -74,15 +75,20 @@ def create_challenge(
acting_player_id=target.id,
challenger_player_id=deep_player.id,
obstacle_ids=json.dumps(obstacle_ids),
stakes=stakes.strip(),
stakes_success=stakes_success.strip(),
stakes_failure=stakes_failure.strip(),
)
db.add(challenge)
db.commit()
obstacle_titles = ", ".join(f"'{o.title}'" for o in game.obstacles if o.id in obstacle_ids)
msg = f"The Deep challenges {target.name}! Applied: {obstacle_titles}."
if stakes.strip():
msg += f" Stakes: {stakes.strip()}"
success_text = stakes_success.strip()
failure_text = stakes_failure.strip()
if success_text:
msg += f" On success: {success_text}"
if failure_text:
msg += f" On failure: {failure_text}"
add_game_event(db, game.id, msg, kind="challenge")
return True, "Challenge called!"

View File

@@ -0,0 +1,34 @@
"""add success/failure stakes to challenge
Revision ID: 5a6e73d2bc4f
Revises: 3bbf6812c261
Create Date: 2026-06-14 09:25:46.637295
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '5a6e73d2bc4f'
down_revision = '3bbf6812c261'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('challenge', schema=None) as batch_op:
batch_op.add_column(sa.Column('stakes_success', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
batch_op.add_column(sa.Column('stakes_failure', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('challenge', schema=None) as batch_op:
batch_op.drop_column('stakes_failure')
batch_op.drop_column('stakes_success')
# ### end Alembic commands ###

View File

@@ -112,7 +112,9 @@ class Challenge(SQLModel, table=True):
challenger_player_id: Optional[str] = Field(default=None) # Deep player or PvP challenger who created it
obstacle_ids: str = Field(default="[]") # JSON list of applied Obstacle ids ("deep" challenges)
temp_card: Optional[str] = Field(default=None) # PvP: challenger's card acting as a temporary Obstacle
stakes: str = Field(default="")
stakes: str = Field(default="") # Deprecated single-field stakes; kept for old rollback-snapshot compatibility. New challenges use stakes_success/stakes_failure.
stakes_success: str = Field(default="") # What the Deep promises happens if the Pi-Rat beats the Challenge
stakes_failure: str = Field(default="") # What the Deep threatens happens if the Pi-Rat fails
plays: str = Field(default="[]") # JSON list: [{"obstacle_id", "card", "player_id", "player_name", "success", "details"}]
status: str = Field(default="open") # "open", "succeeded", "failed"

View File

@@ -14,7 +14,8 @@ def create_challenge_route(
player_id: str,
target_player_id: str = Form(...),
obstacle_ids: str = Form(...), # JSON list of obstacle ids
stakes: str = Form(""),
stakes_success: str = Form(""),
stakes_failure: str = Form(""),
db: Session = Depends(get_session)
):
try:
@@ -22,7 +23,7 @@ def create_challenge_route(
assert isinstance(ids, list)
except Exception:
return JSONResponse({"error": "Invalid obstacle list."}, status_code=400)
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids, stakes)
ok, msg = crud.create_challenge(db, game_id, player_id, target_player_id, ids, stakes_success, stakes_failure)
if not ok:
return JSONResponse({"error": msg}, status_code=400)
return {"status": "ok"}

View File

@@ -194,12 +194,14 @@ def test_scene_start_and_challenges(session):
assert "Challenge" in msg
# The Deep calls a Challenge applying the obstacle
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="The cheese is on the line")
ok, msg = crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes_success="The cheese is yours", stakes_failure="The cheese is on the line")
assert ok, msg
session.refresh(game)
challenge = game.challenges[0]
assert challenge.status == "open"
assert challenge.acting_player_id == p2.id
assert challenge.stakes_success == "The cheese is yours"
assert challenge.stakes_failure == "The cheese is on the line"
orig_is_red = cards.parse_card(obs.original_card)["color"] == "red"
@@ -2078,7 +2080,7 @@ def test_rollback_round_trip_restores_state(session):
cp_known = crud.capture_checkpoint(session, game)
# A destructive action: open a challenge and play a card against the obstacle.
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="x")
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes_failure="x")
crud.capture_checkpoint(session, game)
crud.play_challenge_card(session, p2.id, obs.id, "10D")
crud.capture_checkpoint(session, game)
@@ -2242,7 +2244,7 @@ def test_rollback_middleware_capture_and_route_end_to_end():
obs.current_value = 5
session.add_all([p2, obs])
session.commit()
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes="x")
crud.create_challenge(session, game.id, deep.id, p2.id, [obs.id], stakes_failure="x")
# A floor checkpoint representing the pre-play state.
floor = crud.capture_checkpoint(session, game)