Add gat-description modal when a Pi-Rat earns a Gat

Mirrors the name modal: earning the Gat objective sets
Player.needs_gat_description, and GatModal.svelte (rendered by Dashboard
across all phases) prompts for a one-of-a-kind Gat description, stored in
Player.gat_description and shown on the character sheet. Like a stolen
Name, the Gat's description travels with it through Gat Tax transfers.

- Player.gat_description / needs_gat_description (+ migration)
- toggle_objective personal_1 sets/clears the prompt
- crud_challenge gat-tax paths move the description with the Gat
- set-gat-description route; CharacterSheet displays it

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 09:46:02 -07:00
parent 7825ad2cca
commit 44bbb88836
9 changed files with 155 additions and 1 deletions

View File

@@ -20,7 +20,7 @@
- [x] Add a toast pop-up for new events entering the event log, that then fades into the event log button - [x] Add a toast pop-up for new events entering the event log, that then fades into the event log button
- [ ] When you get a gat, there should be a pop up to enter a description of it, as with your name - [x] When you get a gat, there should be a pop up to enter a description of it, as with your name
- [ ] 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. - [ ] 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.

View File

@@ -0,0 +1,88 @@
<script>
import { apiRequest } from '../lib/api';
export let state;
let descInput = '';
let error = '';
let submitting = false;
async function submitDescription() {
if (!descInput.trim() || submitting) return;
error = '';
submitting = true;
try {
await apiRequest(`/game/${state.game.id}/player/${state.player.id}/set-gat-description`, 'POST', {
description: descInput.trim()
});
descInput = '';
} catch (e) {
error = e.message;
} finally {
submitting = false;
}
}
function onKeydown(e) {
if (e.key === 'Enter') submitDescription();
}
</script>
{#if state.player.needs_gat_description}
<div class="modal-backdrop">
<div class="modal-box glass-panel">
<h3>🔫 You Got a Gat!</h3>
<p class="info-text">
Every Pi-Rat's Gat is one of a kind. What does yours look like?
</p>
{#if error}
<div class="alert alert-danger">{error}</div>
{/if}
<input
type="text"
bind:value={descInput}
class="input-field"
placeholder="e.g. A pearl-handled flintlock that smells of cheese"
on:keydown={onKeydown}
autofocus
>
<button
class="btn btn-gold btn-full"
on:click={submitDescription}
disabled={!descInput.trim() || submitting}
>
Claim Gat
</button>
</div>
</div>
{/if}
<style>
.modal-backdrop {
position: fixed;
inset: 0;
z-index: 2000;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: color-mix(in srgb, var(--bg-deep) 70%, transparent);
backdrop-filter: blur(6px);
}
.modal-box {
max-width: 440px;
width: 100%;
padding: 1.75rem;
border: 1px solid var(--edge);
border-radius: var(--radius-md);
box-shadow: var(--shadow-deep);
text-align: center;
}
.modal-box h3 {
margin-top: 0;
}
.modal-box .input-field {
width: 100%;
margin: 0.75rem 0;
}
</style>

View File

@@ -80,6 +80,9 @@
<p><strong>Look:</strong> {state.player.avatar_look}</p> <p><strong>Look:</strong> {state.player.avatar_look}</p>
<p><strong>Smell:</strong> {state.player.avatar_smell}</p> <p><strong>Smell:</strong> {state.player.avatar_smell}</p>
<p><strong>First Words:</strong> "{state.player.first_words}"</p> <p><strong>First Words:</strong> "{state.player.first_words}"</p>
{#if state.player.gat_description}
<p><strong>🔫 Gat:</strong> {state.player.gat_description}</p>
{/if}
</div> </div>
{#if state.player.other_like || state.player.other_hate} {#if state.player.other_like || state.player.other_hate}

View File

@@ -17,6 +17,7 @@
import GameOverPhase from '../components/GameOverPhase.svelte'; import GameOverPhase from '../components/GameOverPhase.svelte';
import EventLog from '../components/EventLog.svelte'; import EventLog from '../components/EventLog.svelte';
import NameModal from '../components/NameModal.svelte'; import NameModal from '../components/NameModal.svelte';
import GatModal from '../components/GatModal.svelte';
export let params = {}; export let params = {};
let gameId = params.id; let gameId = params.id;
@@ -212,6 +213,7 @@
<EventLog {state} /> <EventLog {state} />
<NameModal {state} /> <NameModal {state} />
<GatModal {state} />
{:else if !error} {:else if !error}
<div class="loading-container"> <div class="loading-container">
<p>Loading game state...</p> <p>Loading game state...</p>

View File

@@ -213,6 +213,9 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
if challenge.tax_type == "gat": if challenge.tax_type == "gat":
acting.completed_personal_1 = False acting.completed_personal_1 = False
refuser.completed_personal_1 = True refuser.completed_personal_1 = True
# The Gat (and its description) returns to its owner.
refuser.gat_description = acting.gat_description
acting.gat_description = ""
else: else:
# Return the stolen name string; the failed thief reverts to # Return the stolen name string; the failed thief reverts to
# their smell-based recruit identity. # their smell-based recruit identity.
@@ -344,6 +347,9 @@ def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool)
if challenge.tax_type == "gat": if challenge.tax_type == "gat":
requester.completed_personal_1 = True requester.completed_personal_1 = True
responder.completed_personal_1 = False responder.completed_personal_1 = False
# The Gat changes hands along with its description (like a stolen Name).
requester.gat_description = responder.gat_description
responder.gat_description = ""
event_msg = f"{responder.name} refused the Gat Tax and must hand over their Gat! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!" event_msg = f"{responder.name} refused the Gat Tax and must hand over their Gat! {requester.name} completes that Objective and attempts the Challenge — succeed to keep it!"
else: else:
# The Name itself is stolen: the requester literally takes the responder's # The Name itself is stolen: the requester literally takes the responder's

View File

@@ -335,8 +335,11 @@ def toggle_objective(db: Session, game_id: str, target_id: str, obj_type: str, s
if obj_type == "personal_1": if obj_type == "personal_1":
if status and not player.completed_personal_1: if status and not player.completed_personal_1:
rank_diff = 1 rank_diff = 1
player.needs_gat_description = True
elif not status and player.completed_personal_1: elif not status and player.completed_personal_1:
rank_diff = -1 rank_diff = -1
player.needs_gat_description = False
player.gat_description = ""
player.completed_personal_1 = status player.completed_personal_1 = status
elif obj_type == "personal_2": elif obj_type == "personal_2":

View File

@@ -0,0 +1,34 @@
"""add gat description to player
Revision ID: 153132c8e583
Revises: 5a6e73d2bc4f
Create Date: 2026-06-14 09:42:58.953909
"""
from alembic import op
import sqlalchemy as sa
import sqlmodel
revision = '153132c8e583'
down_revision = '5a6e73d2bc4f'
branch_labels = None
depends_on = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('player', schema=None) as batch_op:
batch_op.add_column(sa.Column('gat_description', sqlmodel.sql.sqltypes.AutoString(), nullable=False))
batch_op.add_column(sa.Column('needs_gat_description', sa.Boolean(), nullable=False))
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
with op.batch_alter_table('player', schema=None) as batch_op:
batch_op.drop_column('needs_gat_description')
batch_op.drop_column('gat_description')
# ### end Alembic commands ###

View File

@@ -68,8 +68,10 @@ class Player(SQLModel, table=True):
completed_personal_2: bool = Field(default=False) # Earn a Name completed_personal_2: bool = Field(default=False) # Earn a Name
completed_personal_3: bool = Field(default=False) # Die like a Pirate completed_personal_3: bool = Field(default=False) # Die like a Pirate
gat_description: str = Field(default="") # Free-text description of the Pi-Rat's Gat, entered via a modal when they earn one
# States for Milestones & Death # States for Milestones & Death
needs_name: bool = Field(default=False) needs_name: bool = Field(default=False)
needs_gat_description: bool = Field(default=False) # Prompts the gat-description modal after earning a Gat objective
needs_rank_3_bonus: bool = Field(default=False) needs_rank_3_bonus: bool = Field(default=False)
is_dead: bool = Field(default=False) is_dead: bool = Field(default=False)
is_ghost: bool = Field(default=False) is_ghost: bool = Field(default=False)

View File

@@ -130,6 +130,22 @@ def set_name_route(
db.commit() db.commit()
return {"status": "ok"} return {"status": "ok"}
# Pi-Rat describes the Gat they just earned
@router.post("/game/{game_id}/player/{player_id}/set-gat-description")
def set_gat_description_route(
game_id: str,
player_id: str,
description: str = Form(...),
db: Session = Depends(get_session)
):
player = crud.get_player(db, player_id)
if player and player.needs_gat_description:
player.gat_description = description
player.needs_gat_description = False
db.add(player)
db.commit()
return {"status": "ok"}
# Pi-Rat bonus rank up for another player # Pi-Rat bonus rank up for another player
@router.post("/game/{game_id}/player/{player_id}/bonus-rank-up") @router.post("/game/{game_id}/player/{player_id}/bonus-rank-up")
def bonus_rank_up_route( def bonus_rank_up_route(