Require Gat descriptions and keep them consistent across ownership changes

This commit is contained in:
2026-09-04 19:38:20 -07:00
parent a5e3969d0a
commit 53df97e3a5
9 changed files with 123 additions and 8 deletions
+1 -1
View File
@@ -33,7 +33,7 @@
## Fixes ## Fixes
- [ ] Make sure that gat descriptions are a) required and b) displayed on the UI - [x] Make sure that gat descriptions are a) required and b) displayed on the UI
## Words Words Words ## Words Words Words
+4 -1
View File
@@ -28,7 +28,7 @@
} }
</script> </script>
{#if state.player.needs_gat_description} {#if state.player.completed_personal_1 && (state.player.needs_gat_description || !state.player.gat_description?.trim())}
<div class="modal-backdrop"> <div class="modal-backdrop">
<div class="modal-box glass-panel"> <div class="modal-box glass-panel">
<h3>🔫 You Got a Gat!</h3> <h3>🔫 You Got a Gat!</h3>
@@ -45,6 +45,9 @@
placeholder="e.g. A pearl-handled flintlock that smells of cheese" placeholder="e.g. A pearl-handled flintlock that smells of cheese"
on:keydown={onKeydown} on:keydown={onKeydown}
autofocus autofocus
required
maxlength="2000"
aria-label="Gat description"
> >
<button <button
class="btn btn-gold btn-full" class="btn btn-gold btn-full"
+1 -1
View File
@@ -8,7 +8,7 @@
<span class="player-badges"> <span class="player-badges">
<span class="presence" class:online title={connectionLabel} aria-label={connectionLabel}>{online ? '●' : '○'}</span> <span class="presence" class:online title={connectionLabel} aria-label={connectionLabel}>{online ? '●' : '○'}</span>
{#if player.id === state.game.captain_player_id}<span title="Captain" aria-label="Captain">🎩</span>{/if} {#if player.id === state.game.captain_player_id}<span title="Captain" aria-label="Captain">🎩</span>{/if}
{#if player.completed_personal_1}<span title="Has a Gat" aria-label="Has a Gat">🔫</span>{/if} {#if player.completed_personal_1}<span title={player.gat_description?.trim() || 'Gat: waiting for a description'} aria-label={player.gat_description?.trim() ? `Gat: ${player.gat_description}` : 'Gat: waiting for a description'}>🔫</span>{/if}
</span> </span>
<style> <style>
@@ -123,8 +123,8 @@
<p><strong>Look:</strong> {target.avatar_look}</p> <p><strong>Look:</strong> {target.avatar_look}</p>
<p><strong>Smell:</strong> {target.avatar_smell}</p> <p><strong>Smell:</strong> {target.avatar_smell}</p>
<p><strong>First Words:</strong> "{target.first_words}"</p> <p><strong>First Words:</strong> "{target.first_words}"</p>
{#if target.gat_description} {#if target.completed_personal_1}
<p><strong>🔫 Gat:</strong> {target.gat_description}</p> <p><strong>🔫 Gat:</strong> {target.gat_description?.trim() || 'Waiting for a description…'}</p>
{/if} {/if}
</div> </div>
+2 -1
View File
@@ -6,10 +6,11 @@
// - Add a CHANGELOG entry only when a commit changes something players can // - Add a CHANGELOG entry only when a commit changes something players can
// see. Skip refactors, tests, and tooling. Keep wording player-facing. // see. Skip refactors, tests, and tooling. Keep wording player-facing.
export const VERSION = 41; export const VERSION = 42;
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }. // Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
export const CHANGELOG = [ export const CHANGELOG = [
{ version: 42, date: '2026-09-04', changes: ['Gats require a description, including older Gats that were left blank. Read descriptions on character sheets or hover over Gat icons in the crew roster.', 'Gat descriptions and prompts follow Gat Taxes correctly and clear when you create a new recruit.'] },
{ version: 41, date: '2026-09-04', changes: ['Dismiss a completed duel once you have read the result. Other players can still read it, and dismissed results stay hidden after a reload.'] }, { version: 41, date: '2026-09-04', changes: ['Dismiss a completed duel once you have read the result. Other players can still read it, and dismissed results stay hidden after a reload.'] },
{ version: 40, date: '2026-09-04', changes: ['Played cards show a check or cross in the upper corner instead of a green or red outline, so results remain clear in stacked cards.'] }, { version: 40, date: '2026-09-04', changes: ['Played cards show a check or cross in the upper corner instead of a green or red outline, so results remain clear in stacked cards.'] },
{ version: 39, date: '2026-09-04', changes: ['Completed duels stay visible for the rest of the scene, showing the winner, both cards, and how the defense resolved.'] }, { version: 39, date: '2026-09-04', changes: ['Completed duels stay visible for the rest of the scene, showing the winner, both cards, and how the defense resolved.'] },
+4
View File
@@ -239,7 +239,9 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
refuser.completed_personal_1 = True refuser.completed_personal_1 = True
# The Gat (and its description) returns to its owner. # The Gat (and its description) returns to its owner.
refuser.gat_description = acting.gat_description refuser.gat_description = acting.gat_description
refuser.needs_gat_description = not bool(refuser.gat_description.strip())
acting.gat_description = "" acting.gat_description = ""
acting.needs_gat_description = False
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.
@@ -375,7 +377,9 @@ def respond_tax(db: Session, challenge_id: str, responder_id: str, accept: bool)
responder.completed_personal_1 = False responder.completed_personal_1 = False
# The Gat changes hands along with its description (like a stolen Name). # The Gat changes hands along with its description (like a stolen Name).
requester.gat_description = responder.gat_description requester.gat_description = responder.gat_description
requester.needs_gat_description = not bool(requester.gat_description.strip())
responder.gat_description = "" responder.gat_description = ""
responder.needs_gat_description = False
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
+2
View File
@@ -629,6 +629,8 @@ def activate_recruit(db: Session, game: Game, player):
player.is_ghost = False player.is_ghost = False
player.tax_banned = False player.tax_banned = False
player.needs_name = False player.needs_name = False
player.needs_gat_description = False
player.gat_description = ""
player.needs_rank_3_bonus = False player.needs_rank_3_bonus = False
player.completed_personal_1 = False player.completed_personal_1 = False
player.completed_personal_2 = False player.completed_personal_2 = False
+9 -2
View File
@@ -130,8 +130,15 @@ def set_gat_description_route(
db: Session = Depends(get_session) db: Session = Depends(get_session)
): ):
player = crud.get_player(db, player_id) player = crud.get_player(db, player_id)
if player and player.needs_gat_description: if not player or player.game_id != game_id:
player.gat_description = sanitize_text(description) raise HTTPException(status_code=404, detail="Player not found")
if not player.completed_personal_1:
return JSONResponse({"error": "You must have a Gat to describe it."}, status_code=400)
description = sanitize_text(description)
if not description:
return JSONResponse({"error": "Describe your Gat before claiming it."}, status_code=400)
if player.needs_gat_description or not player.gat_description.strip():
player.gat_description = description
player.needs_gat_description = False player.needs_gat_description = False
db.add(player) db.add(player)
db.commit() db.commit()
+98
View File
@@ -2886,3 +2886,101 @@ def test_completed_obstacle_still_awards_deferred_draw(session):
assert crud.get_player_hand(rat) == [] assert crud.get_player_hand(rat) == []
assert crud.resolve_challenge(session, challenge.id, deep.id)[0] assert crud.resolve_challenge(session, challenge.id, deep.id)[0]
assert crud.get_player_hand(rat) == ['AD'] assert crud.get_player_hand(rat) == ['AD']
@pytest.mark.parametrize("description", ["", " \t\n", "\x00\x07"])
def test_gat_description_rejects_blank_input(session, description):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pirats.database import get_session
from pirats.routes_scene import router
game, deep, (rat,) = make_scene_game(session, num_pirats=1)
crud.toggle_objective(session, game.id, rat.id, "personal_1", True)
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_session] = lambda: session
with TestClient(app) as client:
response = client.post(
f"/game/{game.id}/player/{rat.id}/set-gat-description",
data={"description": description},
)
assert response.status_code in (400, 422)
session.refresh(rat)
assert rat.gat_description == ""
assert rat.needs_gat_description
def test_gat_description_saved_and_shared_with_crew(session):
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pirats.database import get_session
from pirats.main import get_game_state
from pirats.routes_scene import router
game, deep, (rat,) = make_scene_game(session, num_pirats=1)
app = FastAPI()
app.include_router(router)
app.dependency_overrides[get_session] = lambda: session
path = f"/game/{game.id}/player/{rat.id}/set-gat-description"
with TestClient(app) as client:
assert client.post(path, data={"description": "Cutlass"}).status_code == 400
# Older Gats may be missing a description without a pending prompt flag.
rat.completed_personal_1 = True
session.add(rat)
session.commit()
assert client.post(
f"/game/wrong-game/player/{rat.id}/set-gat-description",
data={"description": "Cutlass"},
).status_code == 404
assert client.post(path, data={"description": " Pearl\x00-handled flintlock "}).status_code == 200
session.refresh(rat)
assert rat.gat_description == "Pearl-handled flintlock"
assert not rat.needs_gat_description
own_state = get_game_state(game.id, rat.id, session)
crew_state = get_game_state(game.id, deep.id, session)
assert own_state["player"]["gat_description"] == rat.gat_description
assert next(p for p in crew_state["players"] if p["id"] == rat.id)["gat_description"] == rat.gat_description
@pytest.mark.parametrize("description", ["A rusty cutlass", ""])
def test_gat_description_and_prompt_follow_tax_transfer(session, description):
game, deep, (requester, owner) = make_scene_game(session, num_pirats=2)
obstacle = game.obstacles[0]
obstacle.current_value = 13
owner.completed_personal_1 = True
owner.gat_description = description
owner.needs_gat_description = not bool(description)
requester.hand_cards = json.dumps(["2C"])
session.add_all([obstacle, requester, owner])
session.commit()
assert crud.create_challenge(session, game.id, deep.id, requester.id, [obstacle.id])[0]
session.refresh(game)
challenge = game.challenges[0]
assert crud.request_tax(session, challenge.id, requester.id, owner.id)[0]
assert crud.respond_tax(session, challenge.id, owner.id, accept=False)[0]
assert requester.gat_description == description
assert requester.needs_gat_description == (not bool(description))
assert owner.gat_description == ""
assert not owner.needs_gat_description
assert crud.play_challenge_card(session, requester.id, obstacle.id, "2C")[0]
assert crud.resolve_challenge(session, challenge.id, deep.id)[0]
assert owner.gat_description == description
assert owner.needs_gat_description == (not bool(description))
assert requester.gat_description == ""
assert not requester.needs_gat_description
@pytest.mark.parametrize("description", ["A rusty cutlass", ""])
def test_recruit_clears_previous_gat(session, description):
game, deep, (rat,) = make_scene_game(session, num_pirats=1)
rat.completed_personal_1 = True
rat.gat_description = description
rat.needs_gat_description = not bool(description)
session.add(rat)
session.commit()
crud.activate_recruit(session, game, rat)
session.refresh(rat)
assert not rat.completed_personal_1
assert rat.gat_description == ""
assert not rat.needs_gat_description