Improve character suggestions and serve pools from the API

This commit is contained in:
2026-09-04 19:58:35 -07:00
parent 1dac2141e6
commit 0ae1701b82
11 changed files with 255 additions and 1255 deletions
+1 -1
View File
@@ -38,4 +38,4 @@
## Words Words Words
- [x] **TLDR rules**. A one page summary sheet of the rulebook, for the impatient
- [ ] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
- [x] **Suggestions.** Take a pass through all of the suggestions in suggestions.js, rewrite ones that are stiff, awkward, or don't fit the theme/setting as described in the rulebook. Move the suggestion pool from suggestions.js into a backend API endpoint.
@@ -7,6 +7,17 @@
export let state;
let suggestionError = '';
async function suggest(type, apply) {
suggestionError = '';
try {
apply(await getSuggestion(type));
} catch (error) {
suggestionError = 'Could not load suggestions. Try again.';
}
}
// Form states
let basicDetails = {
avatar_look: state.player.avatar_look || '',
@@ -252,6 +263,8 @@
</script>
{#if suggestionError}<p role="alert">{suggestionError}</p>{/if}
<div class="character-creation-view">
<div class="view-header text-center">
<h2>Character Sheet Creation</h2>
@@ -281,28 +294,28 @@
<label for="avatar_look">What does your Pi-Rat look like?</label>
<div class="input-row">
<input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>{/if}
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('look', value => basicDetails.avatar_look = value)}>Suggest</button>{/if}
</div>
</div>
<div class="form-group">
<label for="avatar_smell">What does your Pi-Rat smell like?</label>
<div class="input-row">
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>{/if}
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('smell', value => basicDetails.avatar_smell = value)}>Suggest</button>{/if}
</div>
</div>
<div class="form-group">
<label for="first_words">What were your first words after transforming?</label>
<div class="input-row">
<input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button>{/if}
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('first_words', value => basicDetails.first_words = value)}>Suggest</button>{/if}
</div>
</div>
<div class="form-group">
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
<div class="input-row">
<input type="text" id="good_at_math" placeholder="e.g. Yes, but only geometry; No, thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('good_at_math', value => basicDetails.good_at_math = value)}>Suggest</button>{/if}
</div>
</div>
<div class="input-row mt-4">
@@ -356,7 +369,7 @@
</label>
<div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:like`]} placeholder="e.g. They always share their cheese"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:like`] = getSuggestion('like')}>Suggest</button>{/if}
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('like', value => inboxAnswers[`${p.id}:like`] = value)}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', p.id)}>Submit</button>
</div>
</div>
@@ -369,7 +382,7 @@
</label>
<div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${p.id}:hate`]} placeholder="e.g. They snore too loudly"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${p.id}:hate`] = getSuggestion('hate')}>Suggest</button>{/if}
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('hate', value => inboxAnswers[`${p.id}:hate`] = value)}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${p.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', p.id)}>Submit</button>
</div>
</div>
+19 -6
View File
@@ -6,6 +6,17 @@
export let state;
let suggestionError = '';
async function suggest(type, apply) {
suggestionError = '';
try {
apply(await getSuggestion(type));
} catch (error) {
suggestionError = 'Could not load suggestions. Try again.';
}
}
function getPlayerName(id) {
if (!id) return 'None';
const p = state.players.find(x => x.id === id);
@@ -137,6 +148,8 @@
</script>
{#if suggestionError}<p role="alert">{suggestionError}</p>{/if}
<div class="recruit-phase-view">
<div class="view-header text-center">
<h2>🐀 New Recruits</h2>
@@ -162,28 +175,28 @@
<label for="avatar_look">What does your Pi-Rat look like?</label>
<div class="input-row">
<input type="text" id="avatar_look" placeholder="e.g. A blue bandana, scarred snout" bind:value={basicDetails.avatar_look} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_look = getSuggestion('look')}>Suggest</button>{/if}
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('look', value => basicDetails.avatar_look = value)}>Suggest</button>{/if}
</div>
</div>
<div class="form-group">
<label for="avatar_smell">What does your Pi-Rat smell like? (Determines name)</label>
<div class="input-row">
<input type="text" id="avatar_smell" placeholder="e.g. Damp gunpowder, salty cheese" bind:value={basicDetails.avatar_smell} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.avatar_smell = getSuggestion('smell')}>Suggest</button>{/if}
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('smell', value => basicDetails.avatar_smell = value)}>Suggest</button>{/if}
</div>
</div>
<div class="form-group">
<label for="first_words">What were your first words after transforming?</label>
<div class="input-row">
<input type="text" id="first_words" placeholder="e.g. Where is my gat?!" bind:value={basicDetails.first_words} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.first_words = getSuggestion('first_words')}>Suggest</button>{/if}
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('first_words', value => basicDetails.first_words = value)}>Suggest</button>{/if}
</div>
</div>
<div class="form-group">
<label for="good_at_math">Is your Pi-Rat good at Math?</label>
<div class="input-row">
<input type="text" id="good_at_math" placeholder="e.g. Thinks Pi is a dessert" bind:value={basicDetails.good_at_math} required class="input-field">
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => basicDetails.good_at_math = getSuggestion('good_at_math')}>Suggest</button>{/if}
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('good_at_math', value => basicDetails.good_at_math = value)}>Suggest</button>{/if}
</div>
</div>
<div class="input-row mt-4">
@@ -264,7 +277,7 @@
</label>
<div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:like`]} placeholder="e.g. They always share their cheese"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:like`] = getSuggestion('like')}>Suggest</button>{/if}
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('like', value => inboxAnswers[`${task.recruit.id}:like`] = value)}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:like`] || '').trim()} on:click={() => submitDelegatedAnswer('like', task.recruit.id)}>Submit</button>
</div>
</div>
@@ -277,7 +290,7 @@
</label>
<div class="input-row">
<input type="text" class="input-field" bind:value={inboxAnswers[`${task.recruit.id}:hate`]} placeholder="e.g. They snore too loudly"/>
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => inboxAnswers[`${task.recruit.id}:hate`] = getSuggestion('hate')}>Suggest</button>{/if}
{#if devMode}<button type="button" class="btn btn-secondary btn-small" on:click={() => suggest('hate', value => inboxAnswers[`${task.recruit.id}:hate`] = value)}>Suggest</button>{/if}
<button class="btn btn-primary" disabled={!(inboxAnswers[`${task.recruit.id}:hate`] || '').trim()} on:click={() => submitDelegatedAnswer('hate', task.recruit.id)}>Submit</button>
</div>
</div>
+2 -1
View File
@@ -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 = 46;
export const VERSION = 47;
// Newest first. Each entry: { version, date: 'YYYY-MM-DD', changes: [string, ...] }.
export const CHANGELOG = [
{ version: 47, date: '2026-09-04', changes: ['Rewrote character suggestions with scrappy pirate looks, distinctive smells, first words, and crew quirks that fit Yeld.'] },
{ version: 46, date: '2026-09-04', changes: ['Added a printable TL;DR rules page, linked at the top of the full rulebook.'] },
{ version: 44, date: '2026-09-04', changes: ['Crew Objectives opens as one connected panel, with the toggle and checklist sharing a border and background.'] },
{ version: 43, date: '2026-09-04', changes: ['The crew roster keeps players in the same order when roles or ranks change, including when someone becomes the Deep.'] },
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -113,18 +113,18 @@
async function skipCharacterCreation() {
// Suggested values for everyone's free-text fields; the backend only
// applies them to fields players haven't filled in themselves.
try {
const fills = {};
for (const p of state.players) {
fills[p.id] = {
avatar_look: getSuggestion('look'),
avatar_smell: getSuggestion('smell'),
first_words: getSuggestion('first_words'),
good_at_math: getSuggestion('good_at_math'),
like: getSuggestion('like'),
hate: getSuggestion('hate'),
avatar_look: await getSuggestion('look'),
avatar_smell: await getSuggestion('smell'),
first_words: await getSuggestion('first_words'),
good_at_math: await getSuggestion('good_at_math'),
like: await getSuggestion('like'),
hate: await getSuggestion('hate'),
};
}
try {
await apiRequest(`/game/${gameId}/player/${playerId}/skip-character-creation`, 'POST', {
fills: JSON.stringify(fills)
});
+31
View File
@@ -0,0 +1,31 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const asModule = source => `data:text/javascript;base64,${Buffer.from(source).toString('base64')}`;
const api = asModule(await readFile(new URL('../src/lib/api.js', import.meta.url), 'utf8'));
const source = (await readFile(new URL('../src/lib/suggestions.js', import.meta.url), 'utf8'))
.replace("'./api'", JSON.stringify(api));
test('suggestions retry failed requests, share the pool, and respect exclusions', async () => {
const originalFetch = globalThis.fetch;
let calls = 0;
globalThis.fetch = async url => {
calls++;
if (calls === 1) throw new Error('offline');
assert.equal(url, '/api/suggestions');
return { ok: true, json: async () => ({ look: ['Bandana', 'Eyepatch'], smell: ['Tar'] }) };
};
try {
const { getSuggestion } = await import(asModule(source));
await assert.rejects(getSuggestion('look'), /offline/);
assert.deepEqual(await Promise.all([
getSuggestion('look', [' bandana ']), getSuggestion('smell')
]), ['Eyepatch', 'Tar']);
assert.equal(calls, 2);
assert.equal(await getSuggestion('unknown'), '');
assert.ok(['Bandana', 'Eyepatch'].includes(await getSuggestion('look', ['Bandana', 'Eyepatch'])));
} finally {
globalThis.fetch = originalFetch;
}
});
+1 -1
View File
@@ -502,7 +502,7 @@ def skip_character_creation(db: Session, game: Game, fills: dict):
creation for ALL players and advances to scene setup. `fills` maps
player_id -> suggested values for the free-text fields (avatar_look,
avatar_smell, first_words, good_at_math, like, hate), supplied by the
admin's client from the frontend suggestion pools. Techniques come from
admin's client from the backend suggestion API. Techniques come from
the backend pool. Already-filled fields are left untouched.
"""
from .cards import TECHNIQUE_SUGGESTIONS
+6
View File
@@ -4,10 +4,16 @@ from sqlmodel import Session
from .database import get_session
from . import crud
from .cards import TECHNIQUE_SUGGESTIONS
from .suggestions import CHARACTER_SUGGESTIONS
from .validation import sanitize_text, sanitize_name
router = APIRouter()
@router.get("/suggestions")
def character_suggestions():
return CHARACTER_SUGGESTIONS
# Secret Pirate Technique name pool (for the frontend's Suggest buttons)
@router.get("/suggestions/techniques")
def technique_suggestions():
+126
View File
@@ -0,0 +1,126 @@
"""Hand-written Rat Records prompts for the scrappy fantasy pirates of Yeld.
Smells also serve as temporary names; keep them short noun phrases. Math
is a personality joke, not a power or a rule benefit.
"""
CHARACTER_SUGGESTIONS = {'look': ['A red bandana with two holes chewed through for ears',
'An eyepatch that keeps sliding onto the wrong eye',
'A stolen waistcoat with pockets full of biscuit crumbs',
'Whiskers braided with bits of sail thread',
'A tail tied in a knot so nobody steps on it',
'A tiny skull painted between the ears',
"A coat made from the ship's old flag",
'One gold tooth and a grin that shows it off',
'A belt made of rope, with far too many knots',
'A hat so wide it catches every gust of wind',
'Soot-black paws and a suspiciously innocent expression',
'A scar across the snout that they insist looks impressive',
'A brass button worn as a medal',
'A striped shirt still bearing the laundry tag',
'A fork tucked behind one ear for emergencies',
'A tail wrapped in a strip of stolen velvet',
'A necklace of fish bones and one very nice pearl',
"Fur slicked back with the cook's best butter",
'A chalk handprint on the back of a borrowed coat',
'An enormous hat held on with string'],
'smell': ['Gunpowder and wet fur',
'Old cheese and fresh tar',
'Pickle brine',
'Burnt toast',
'Fish guts and cheap perfume',
'Damp rope',
'Sour grog',
'Peppermint and bilge water',
'Smoked herring',
'Candle wax and singed whiskers',
'Mouldy biscuits',
'Salt pork and garlic',
'Seaweed left in the sun',
'Stolen jam',
'Wet wool and black pepper',
'Boot polish',
'Rancid butter',
'Lemon peel and cannon smoke',
'Onions and damp wood',
'Something dead behind the pantry'],
'first_words': ["Who's been keeping all this cheese up here?",
"I've got thumbs! Nobody panic!",
'Right. Which end of the ship do we steal first?',
"That hat's mine now.",
'I liked the captain better when I could fit in his boot.',
'I demand a bigger lunch.',
'Can we eat the map?',
'Nobody tell the cat.',
"I can explain. Actually, no I can't.",
'Why is the floor suddenly so far away?',
'Hand over the biscuits and nobody gets bitten.',
'I vote we keep the ship.',
'Does this mean I have to wear trousers?',
"I've always wanted to shout this: BOARD THEM!",
'Somebody fetch me a dangerous stick.',
'I know three words and two of them are threats.',
'Was that your cheese? Unfortunate.',
'I can count! We are outnumbered!',
"Let's point the cannon at whoever owns it.",
'I was already this clever. Ask anyone.'],
'good_at_math': ['Yes, until someone starts watching.',
'Only when dividing up stolen food.',
'No, but very loud about the answer.',
'Can count every coin in a purse by shaking it.',
'Knows that two lunches are better than one.',
"Counts on their claws, then borrows someone else's.",
'Brilliant at sums. Cannot tell left from right.',
'Insists every problem can be solved by adding more rats.',
'Yes, and will prove it on your coat in chalk.',
'Can divide loot fairly. Chooses not to.',
'Thinks fractions are what happens when you bite a biscuit.',
'Keeps losing count when their tail moves.',
'No. Has appointed a beetle to handle the numbers.',
'Can calculate a cannon shot but not the price of lunch.',
'Gets the right answer for entirely the wrong reasons.',
'Counts backwards whenever frightened.',
'Only if the numbers are small enough to threaten.',
'Knows pi to several places, all of them wrong.',
"Refuses to subtract. That's how things go missing.",
'Perfectly average, which they find deeply suspicious.'],
'like': ['They always save you the least mouldy biscuit.',
"They'll bite anyone who threatens the crew.",
'They remember which hammock is yours.',
'They take the blame when a good prank goes wrong.',
'They can make a feast out of stolen scraps.',
'They laugh at your jokes, even during a mutiny.',
'They share their last dry match.',
'They never leave a rat behind.',
'They know when to stop talking and start chewing through ropes.',
'They tell excellent lies on your behalf.',
'They warm your paws when the night watch gets cold.',
'They always volunteer to distract the cat.',
'They can find food in an empty cupboard.',
'They mend your clothes without being asked.',
'They cheer loudest when you do something reckless.',
'They keep a lookout while you sneak an extra ration.',
'They turn every terrible plan into a team effort.',
'They know a song for every disaster.',
'They give the best apologies, usually with snacks.',
'They trust you with the good end of the rope.'],
'hate': ['They eat the evidence before anyone can examine it.',
'They borrow your boots and return only one.',
"They practice their captain voice while you're sleeping.",
'They leave fish bones in your hammock.',
'They insist every plan was their idea.',
'They lick the best biscuit before offering to share.',
"They chew through ropes without checking what they're holding up.",
'They shout "Land ho!" whenever they\'re bored.',
'They use your coat to wipe cannon soot off their paws.',
'They keep a secret food stash that everyone can smell.',
'They correct your sums in the middle of a fight.',
'They drum on empty barrels during the night watch.',
'They promise your share of the loot to strangers.',
'They pick their teeth with your favourite knife.',
'They give away the hiding place by giggling.',
"They refuse to admit they can't read the map.",
'They shake seawater onto everyone after a swim.',
'They start a new verse just when the song seems over.',
'They call every creature with fins their cousin.',
"They always ask if you're going to finish that, mid-bite."]}
+20
View File
@@ -0,0 +1,20 @@
from fastapi.testclient import TestClient
from pirats.main import app
from pirats.validation import sanitize_name, sanitize_text
def test_suggestions_api_covers_rat_records_without_truncation():
# No lifespan needed: these public endpoints do not access the database.
client = TestClient(app)
response = client.get('/api/suggestions')
assert response.status_code == 200
pools = response.json()
assert set(pools) == {'look', 'smell', 'first_words', 'good_at_math', 'like', 'hate'}
for category, entries in pools.items():
assert len(entries) >= 20
assert len({entry.casefold() for entry in entries}) == len(entries)
sanitize = sanitize_name if category == 'smell' else sanitize_text
assert all(entry and sanitize(entry) == entry for entry in entries)
techniques = client.get('/api/suggestions/techniques')
assert techniques.status_code == 200
assert techniques.json()['techniques']