-
Notifications
You must be signed in to change notification settings - Fork 18
feat: buddy color progression (species x rarity x XP) #126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
25 commits
Select commit
Hold shift + click to select a range
4abfbdc
docs: add buddy color progression design spec
Ldaco 4bbfb3a
docs: clarify saturation tint scope, name color, and bridge muddiness
Ldaco dd2eb56
docs: add buddy color progression implementation plan
Ldaco caf45c6
feat(color): scaffold color module with types and NEUTRAL_GRAY
Ldaco a16c673
feat(color): add 21-species palette table
Ldaco 0fd8e4d
feat(color): add rarity metal anchors and saturation tints
Ldaco 2bc3fa2
feat(color): add clamp and lerpRGB helpers
Ldaco 1697809
feat(color): add rampPosition to map XP onto [0, 1] curve
Ldaco 630212c
feat(color): add interpolateAnchors for piecewise RGB lerp
Ldaco ce16f68
feat(color): add applySaturationTint rarity modulation
Ldaco 74d1e2d
feat(color): add computeRGB composition function
Ldaco 0eb6476
test(color): add computeRGB tests for bridge and metal segments
Ldaco 62bc21b
feat(color): add terminal capability detection cascade
Ldaco 5a1b5ff
feat(color): add rgbTo256 quantization for 256-color terminals
Ldaco 4298217
feat(color): add rgbToAnsi16 for 16-color terminal fallback
Ldaco fc7751e
feat(color): add colorFor public API with capability-driven output
Ldaco 3b4c813
test(color): snapshot fixtures lock visual contract for 15 cases
Ldaco 7a8df7f
feat(statusline): use colorFor for sprite art in normal mode
Ldaco 9748471
feat(statusline): use colorFor for sprite art in bubble mode
Ldaco ceaf144
feat(card): colorize sprite lines via colorFor in renderCard
Ldaco f31a318
feat(share): colorize sprite PNG via computeRGB
Ldaco 1b6c3d5
chore: remove transient spec and plan docs
Ldaco a41bd34
fix(color): address Copilot review on PR #126
Ldaco 6cf9891
fix(color): address Copilot review round 2
Ldaco d8aa263
test(color): end-to-end capability tier fallback verification
Ldaco File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| // Renders the same buddy card under each of the four terminal capability tiers | ||
| // so reviewers can eyeball the actual output before merge. Pairs with the | ||
| // end-to-end assertions in src/__tests__/card-color.test.ts. | ||
| // | ||
| // Usage: npm run build && node scripts/verify-color-tiers.mjs | ||
|
|
||
| import { renderCard } from '../dist/lib/card.js'; | ||
| import { totalXpForLevel } from '../dist/lib/leveling.js'; | ||
|
|
||
| const TIERS = [ | ||
| { name: 'NO_COLOR', caps: { truecolor: false, ansi256: false, ansi16: false, noColor: true } }, | ||
| { name: 'ANSI-16', caps: { truecolor: false, ansi256: false, ansi16: true, noColor: false } }, | ||
| { name: 'ANSI-256', caps: { truecolor: false, ansi256: true, ansi16: false, noColor: false } }, | ||
| { name: 'truecolor', caps: { truecolor: true, ansi256: false, ansi16: false, noColor: false } }, | ||
| ]; | ||
|
|
||
| const companion = { | ||
| name: 'Steven', | ||
| personalityBio: 'a sample buddy used to verify each color tier.', | ||
| rarity: 'rare', | ||
| species: 'Cactus', | ||
| eye: '·', | ||
| hat: 'none', | ||
| shiny: false, | ||
| stats: { DEBUGGING: 50, PATIENCE: 40, CHAOS: 30, WISDOM: 20, SNARK: 10 }, | ||
| level: 25, | ||
| xp: totalXpForLevel(25), | ||
| mood: 'neutral', | ||
| availablePoints: 0, | ||
| hatchedAt: Date.now(), | ||
| }; | ||
|
|
||
| const ESC = /\x1b\[[^m]*m/g; | ||
|
|
||
| function describe(card) { | ||
| const escapes = card.match(ESC) ?? []; | ||
| const has24bit = card.includes('\x1b[38;2;'); | ||
| const has256 = card.includes('\x1b[38;5;'); | ||
| const has16 = /\x1b\[3[0-7]m/.test(card); | ||
| return { | ||
| escapeCount: escapes.length, | ||
| has24bit, | ||
| has256, | ||
| has16, | ||
| }; | ||
| } | ||
|
|
||
| for (const { name, caps } of TIERS) { | ||
| const card = renderCard(companion, caps); | ||
| const stats = describe(card); | ||
| console.log('='.repeat(60)); | ||
| console.log(`tier: ${name}`); | ||
| console.log(`escapes emitted: ${stats.escapeCount}` + | ||
| ` | 24-bit: ${stats.has24bit} | 256-color: ${stats.has256} | 16-color: ${stats.has16}`); | ||
| console.log('-'.repeat(60)); | ||
| console.log(card); | ||
| console.log(); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html | ||
|
|
||
| exports[`color fixtures (snapshot contract) > common-cactus-lv1 renders to a stable ANSI string 1`] = `"[38;2;151;134;93m"`; | ||
|
|
||
| exports[`color fixtures (snapshot contract) > common-robot-lv50-iron renders to a stable ANSI string 1`] = `"[38;2;137;137;140m"`; | ||
|
|
||
| exports[`color fixtures (snapshot contract) > epic-cactus-lv1 renders to a stable ANSI string 1`] = `"[1m[38;2;158;136;82m"`; | ||
|
|
||
| exports[`color fixtures (snapshot contract) > epic-robot-lv50-diamond renders to a stable ANSI string 1`] = `"[1m[38;2;231;251;255m"`; | ||
|
|
||
| exports[`color fixtures (snapshot contract) > fallback-pegasus renders to a stable ANSI string 1`] = `"[38;2;102;102;102m"`; | ||
|
|
||
| exports[`color fixtures (snapshot contract) > legendary-cactus-lv1 renders to a stable ANSI string 1`] = `"[1m[38;2;160;136;79m"`; | ||
|
|
||
| exports[`color fixtures (snapshot contract) > legendary-robot-lv50-aurum renders to a stable ANSI string 1`] = `"[1m[38;2;255;255;238m"`; | ||
|
|
||
| exports[`color fixtures (snapshot contract) > rare-cactus-lv1 renders to a stable ANSI string 1`] = `"[1m[38;2;156;135;85m"`; | ||
|
|
||
| exports[`color fixtures (snapshot contract) > rare-penguin-lv35-bridge renders to a stable ANSI string 1`] = `"[1m[38;2;152;190;102m"`; | ||
|
|
||
| exports[`color fixtures (snapshot contract) > rare-robot-lv50-gold renders to a stable ANSI string 1`] = `"[1m[38;2;250;205;69m"`; | ||
|
|
||
| exports[`color fixtures (snapshot contract) > uncommon-cactus-lv1 renders to a stable ANSI string 1`] = `"[38;2;155;135;87m"`; | ||
|
|
||
| exports[`color fixtures (snapshot contract) > uncommon-octopus-lv10 renders to a stable ANSI string 1`] = `"[38;2;90;73;166m"`; | ||
|
|
||
| exports[`color fixtures (snapshot contract) > uncommon-octopus-lv20 renders to a stable ANSI string 1`] = `"[38;2;63;134;211m"`; | ||
|
|
||
| exports[`color fixtures (snapshot contract) > uncommon-octopus-lv30 renders to a stable ANSI string 1`] = `"[38;2;62;211;195m"`; | ||
|
|
||
| exports[`color fixtures (snapshot contract) > uncommon-robot-lv50-copper renders to a stable ANSI string 1`] = `"[38;2;184;138;94m"`; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { renderCard } from '../lib/card.js'; | ||
| import { colorFor, type TerminalCapabilities } from '../lib/color.js'; | ||
| import type { Companion } from '../lib/types.js'; | ||
|
|
||
| function makeCompanion(overrides: Partial<Companion> = {}): Companion { | ||
| return { | ||
| name: 'Testy', | ||
| personalityBio: 'A test buddy.', | ||
| rarity: 'rare', | ||
| species: 'Cactus', | ||
| eye: '·', | ||
| hat: 'none', | ||
| shiny: false, | ||
| stats: { DEBUGGING: 50, PATIENCE: 40, CHAOS: 30, WISDOM: 20, SNARK: 10 }, | ||
| level: 1, | ||
| xp: 0, | ||
| mood: 'neutral', | ||
| availablePoints: 0, | ||
| hatchedAt: Date.now(), | ||
| ...overrides, | ||
| }; | ||
| } | ||
|
|
||
| describe('renderCard color integration', () => { | ||
| it('sprite lines are wrapped in colorFor escape', () => { | ||
| const companion = makeCompanion({ species: 'Cactus', rarity: 'rare', xp: 0 }); | ||
| const card = renderCard(companion); | ||
| const expectedColor = colorFor('Cactus', 'rare', 0); | ||
| // Guard: under NO_COLOR, colorFor returns '' and toContain('') is vacuously true. | ||
| // Fail loudly if the test env can't actually exercise the colorization path. | ||
| expect(expectedColor).not.toBe(''); | ||
| expect(card).toContain(expectedColor); | ||
| }); | ||
|
|
||
| it('different rarities produce different color codes in card output', () => { | ||
| const common = renderCard(makeCompanion({ rarity: 'common' })); | ||
| const legendary = renderCard(makeCompanion({ rarity: 'legendary' })); | ||
| expect(common).not.toEqual(legendary); | ||
| }); | ||
|
|
||
| it('Lv 1 vs Lv 50 same buddy produce different color codes', () => { | ||
| const lv1 = renderCard(makeCompanion({ level: 1, xp: 0 })); | ||
| const lv50 = renderCard(makeCompanion({ level: 50, xp: 100000 })); | ||
| expect(lv1).not.toEqual(lv50); | ||
| }); | ||
| }); | ||
|
|
||
| // End-to-end capability tier fallback. Steven's PR #126 review asked us to | ||
| // verify NO_COLOR / ANSI-16 / ANSI-256 / truecolor before merge: these tests | ||
| // drive renderCard with an explicit TerminalCapabilities and assert the *shape* | ||
| // of the escapes in the actual card output — closing the loop the unit tests | ||
| // in color.test.ts leave open (those only assert colorFor's return value, not | ||
| // what renderCard ultimately emits). | ||
| describe('renderCard capability tier fallback (end-to-end)', () => { | ||
| const TRUECOLOR: TerminalCapabilities = { truecolor: true, ansi256: false, ansi16: false, noColor: false }; | ||
| const ANSI256: TerminalCapabilities = { truecolor: false, ansi256: true, ansi16: false, noColor: false }; | ||
| const ANSI16: TerminalCapabilities = { truecolor: false, ansi256: false, ansi16: true, noColor: false }; | ||
| const NOCOLOR: TerminalCapabilities = { truecolor: false, ansi256: false, ansi16: false, noColor: true }; | ||
|
|
||
| it('NO_COLOR → card output contains zero ANSI escape sequences', () => { | ||
| const card = renderCard(makeCompanion({ rarity: 'rare' }), NOCOLOR); | ||
| expect(card).not.toMatch(/\x1b\[/); | ||
| }); | ||
|
|
||
| it('ansi16 → emits only 16-color escapes (no 38;5 or 38;2 sequences)', () => { | ||
| const card = renderCard(makeCompanion({ rarity: 'rare' }), ANSI16); | ||
| expect(card).not.toContain('38;5'); | ||
| expect(card).not.toContain('38;2'); | ||
| expect(card).toMatch(/\x1b\[3[0-7]m/); | ||
| }); | ||
|
|
||
| it('ansi256 → emits 256-color escapes and no truecolor 38;2 sequences', () => { | ||
| const card = renderCard(makeCompanion({ rarity: 'rare' }), ANSI256); | ||
| expect(card).toMatch(/\x1b\[38;5;\d+m/); | ||
| expect(card).not.toContain('38;2'); | ||
| }); | ||
|
|
||
| it('truecolor → emits 24-bit truecolor escapes', () => { | ||
| const card = renderCard(makeCompanion({ rarity: 'rare' }), TRUECOLOR); | ||
| expect(card).toMatch(/\x1b\[38;2;\d+;\d+;\d+m/); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { describe, it, expect } from 'vitest'; | ||
| import { colorFor, type TerminalCapabilities } from '../lib/color.js'; | ||
| import { totalXpForLevel } from '../lib/leveling.js'; | ||
|
|
||
| const TRUECOLOR: TerminalCapabilities = { | ||
| truecolor: true, ansi256: false, ansi16: false, noColor: false, | ||
| }; | ||
|
|
||
| interface Fixture { | ||
| species: string; | ||
| rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; | ||
| level: number; | ||
| label: string; | ||
| } | ||
|
|
||
| const FIXTURES: Fixture[] = [ | ||
| { species: 'Cactus', rarity: 'common', level: 1, label: 'common-cactus-lv1' }, | ||
| { species: 'Cactus', rarity: 'uncommon', level: 1, label: 'uncommon-cactus-lv1' }, | ||
| { species: 'Cactus', rarity: 'rare', level: 1, label: 'rare-cactus-lv1' }, | ||
| { species: 'Cactus', rarity: 'epic', level: 1, label: 'epic-cactus-lv1' }, | ||
| { species: 'Cactus', rarity: 'legendary', level: 1, label: 'legendary-cactus-lv1' }, | ||
|
|
||
| { species: 'Octopus', rarity: 'uncommon', level: 10, label: 'uncommon-octopus-lv10' }, | ||
| { species: 'Octopus', rarity: 'uncommon', level: 20, label: 'uncommon-octopus-lv20' }, | ||
| { species: 'Octopus', rarity: 'uncommon', level: 30, label: 'uncommon-octopus-lv30' }, | ||
|
|
||
| { species: 'Penguin', rarity: 'rare', level: 35, label: 'rare-penguin-lv35-bridge' }, | ||
|
|
||
| { species: 'Robot', rarity: 'common', level: 50, label: 'common-robot-lv50-iron' }, | ||
| { species: 'Robot', rarity: 'uncommon', level: 50, label: 'uncommon-robot-lv50-copper' }, | ||
| { species: 'Robot', rarity: 'rare', level: 50, label: 'rare-robot-lv50-gold' }, | ||
| { species: 'Robot', rarity: 'epic', level: 50, label: 'epic-robot-lv50-diamond' }, | ||
| { species: 'Robot', rarity: 'legendary', level: 50, label: 'legendary-robot-lv50-aurum' }, | ||
|
|
||
| { species: 'Pegasus', rarity: 'uncommon', level: 1, label: 'fallback-pegasus' }, | ||
| ]; | ||
|
|
||
| describe('color fixtures (snapshot contract)', () => { | ||
| for (const fx of FIXTURES) { | ||
| it(`${fx.label} renders to a stable ANSI string`, () => { | ||
| const xp = fx.level === 1 ? 0 : totalXpForLevel(fx.level); | ||
| const escape = colorFor(fx.species, fx.rarity, xp, TRUECOLOR); | ||
| expect(escape).toMatchSnapshot(); | ||
| }); | ||
| } | ||
| }); |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.