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:
2
TODO.md
2
TODO.md
@@ -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.
|
||||||
|
|
||||||
|
|||||||
88
frontend/src/components/GatModal.svelte
Normal file
88
frontend/src/components/GatModal.svelte
Normal 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>
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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":
|
||||||
|
|||||||
@@ -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 ###
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user