Require Gat descriptions and keep them consistent across ownership changes
This commit is contained in:
@@ -33,7 +33,7 @@
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
}
|
||||
</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-box glass-panel">
|
||||
<h3>🔫 You Got a Gat!</h3>
|
||||
@@ -45,6 +45,9 @@
|
||||
placeholder="e.g. A pearl-handled flintlock that smells of cheese"
|
||||
on:keydown={onKeydown}
|
||||
autofocus
|
||||
required
|
||||
maxlength="2000"
|
||||
aria-label="Gat description"
|
||||
>
|
||||
<button
|
||||
class="btn btn-gold btn-full"
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<span class="player-badges">
|
||||
<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.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>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -123,8 +123,8 @@
|
||||
<p><strong>Look:</strong> {target.avatar_look}</p>
|
||||
<p><strong>Smell:</strong> {target.avatar_smell}</p>
|
||||
<p><strong>First Words:</strong> "{target.first_words}"</p>
|
||||
{#if target.gat_description}
|
||||
<p><strong>🔫 Gat:</strong> {target.gat_description}</p>
|
||||
{#if target.completed_personal_1}
|
||||
<p><strong>🔫 Gat:</strong> {target.gat_description?.trim() || 'Waiting for a description…'}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
// - Add a CHANGELOG entry only when a commit changes something players can
|
||||
// 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, ...] }.
|
||||
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: 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.'] },
|
||||
|
||||
@@ -239,7 +239,9 @@ def resolve_challenge(db: Session, challenge_id: str, resolver_id: str) -> Tuple
|
||||
refuser.completed_personal_1 = True
|
||||
# The Gat (and its description) returns to its owner.
|
||||
refuser.gat_description = acting.gat_description
|
||||
refuser.needs_gat_description = not bool(refuser.gat_description.strip())
|
||||
acting.gat_description = ""
|
||||
acting.needs_gat_description = False
|
||||
else:
|
||||
# Return the stolen name string; the failed thief reverts to
|
||||
# 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
|
||||
# The Gat changes hands along with its description (like a stolen Name).
|
||||
requester.gat_description = responder.gat_description
|
||||
requester.needs_gat_description = not bool(requester.gat_description.strip())
|
||||
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!"
|
||||
else:
|
||||
# The Name itself is stolen: the requester literally takes the responder's
|
||||
|
||||
@@ -629,6 +629,8 @@ def activate_recruit(db: Session, game: Game, player):
|
||||
player.is_ghost = False
|
||||
player.tax_banned = False
|
||||
player.needs_name = False
|
||||
player.needs_gat_description = False
|
||||
player.gat_description = ""
|
||||
player.needs_rank_3_bonus = False
|
||||
player.completed_personal_1 = False
|
||||
player.completed_personal_2 = False
|
||||
|
||||
@@ -130,8 +130,15 @@ def set_gat_description_route(
|
||||
db: Session = Depends(get_session)
|
||||
):
|
||||
player = crud.get_player(db, player_id)
|
||||
if player and player.needs_gat_description:
|
||||
player.gat_description = sanitize_text(description)
|
||||
if not player or player.game_id != game_id:
|
||||
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
|
||||
db.add(player)
|
||||
db.commit()
|
||||
|
||||
@@ -2886,3 +2886,101 @@ def test_completed_obstacle_still_awards_deferred_draw(session):
|
||||
assert crud.get_player_hand(rat) == []
|
||||
assert crud.resolve_challenge(session, challenge.id, deep.id)[0]
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user