Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class SettingsRepository(
overlayScale = sanitized.overlayScale,
battlePollingIntervalMs = sanitized.battlePollingIntervalMs,
mapFollowSmoothingPercent = sanitized.mapFollowSmoothingPercent,
highVisibilityMapPlayer = sanitized.highVisibilityMapPlayer,
)
val override = StoredSettings.difference(sanitized, globals)
val overrides = LinkedHashMap(state.romOverrides)
Expand Down Expand Up @@ -194,6 +195,7 @@ class SettingsRepository(
},
battlePollingIntervalMs = if (includeDeviceFields) objectValue.intValue("battlePollingIntervalMs")?.coerceIn(1, 20) else null,
mapFollowSmoothingPercent = if (includeDeviceFields) objectValue.intValue("mapFollowSmoothingPercent")?.coerceIn(0, 100) else null,
highVisibilityMapPlayer = if (includeDeviceFields) objectValue.booleanValue("highVisibilityMapPlayer") else null,
)

private fun sanitize(settings: CompanionSettings): CompanionSettings {
Expand Down Expand Up @@ -268,6 +270,7 @@ class SettingsRepository(
val overlayScale: Double? = null,
val battlePollingIntervalMs: Int? = null,
val mapFollowSmoothingPercent: Int? = null,
val highVisibilityMapPlayer: Boolean? = null,
) {
fun applyTo(fallback: CompanionSettings): CompanionSettings = CompanionSettings(
knowledgeMode = knowledgeMode?.let(KnowledgeMode::valueOf) ?: fallback.knowledgeMode,
Expand All @@ -285,6 +288,7 @@ class SettingsRepository(
overlayScale = overlayScale ?: fallback.overlayScale,
battlePollingIntervalMs = battlePollingIntervalMs ?: fallback.battlePollingIntervalMs,
mapFollowSmoothingPercent = mapFollowSmoothingPercent ?: fallback.mapFollowSmoothingPercent,
highVisibilityMapPlayer = highVisibilityMapPlayer ?: fallback.highVisibilityMapPlayer,
)

fun isEmpty(): Boolean = this == StoredSettings()
Expand All @@ -306,6 +310,7 @@ class SettingsRepository(
overlayScale = settings.overlayScale,
battlePollingIntervalMs = settings.battlePollingIntervalMs,
mapFollowSmoothingPercent = settings.mapFollowSmoothingPercent,
highVisibilityMapPlayer = settings.highVisibilityMapPlayer,
)

fun difference(settings: CompanionSettings, globals: CompanionSettings) = StoredSettings(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1531,6 +1531,8 @@ class ProductionCompanionRuntime(
?: current.battlePollingIntervalMs,
mapFollowSmoothingPercent = values["mapFollowSmoothingPercent"]?.toIntOrNull()?.coerceIn(0, 100)
?: current.mapFollowSmoothingPercent,
highVisibilityMapPlayer = values["highVisibilityMapPlayer"]?.toBooleanStrictOrNull()
?: current.highVisibilityMapPlayer,
)
gateway.dispatch(
CompanionAction.UpdateSettings(updated),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class SettingsRepositoryTest {
overlayScale = 0.65,
battlePollingIntervalMs = 1,
mapFollowSmoothingPercent = 70,
highVisibilityMapPlayer = true,
)

repository.write(settings)
Expand Down Expand Up @@ -109,6 +110,28 @@ class SettingsRepositoryTest {
assertFalse(requireNotNull(document).substringAfter("\"romOverrides\"").contains("mapFollowSmoothingPercent"))
}

@Test
fun keepsTheHighVisibilityMapPlayerPreferenceDeviceGlobalAcrossRomProfiles() {
var document: String? = null
val repository = SettingsRepository({ document }, { document = it })
repository.writeForRom(
romA,
CompanionSettings(theme = Theme.DARK, highVisibilityMapPlayer = true),
)
repository.writeForRom(
romB,
repository.readForRom(romB).copy(theme = Theme.LIGHT),
)

assertTrue(repository.readForRom(romA).highVisibilityMapPlayer)
assertTrue(repository.readForRom(romB).highVisibilityMapPlayer)
assertFalse(
requireNotNull(document)
.substringAfter("\"romOverrides\"")
.contains("highVisibilityMapPlayer"),
)
}

@Test
fun storesIndependentRomOverridesWhileDeviceSettingsRemainGlobal() {
var document: String? = null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1754,14 +1754,20 @@ class ProductionCompanionRuntimeTest {

val state = runtime.action(
"SETTINGS",
mapOf("displayTarget" to "EXTERNAL", "theme" to "LIGHT", "fontScale" to "1.2"),
mapOf(
"displayTarget" to "EXTERNAL",
"theme" to "LIGHT",
"fontScale" to "1.2",
"highVisibilityMapPlayer" to "true",
),
)

val settings = state.settings as CompanionSettings
assertEquals(KnowledgeMode.DISCOVERED, settings.knowledgeMode)
assertEquals(DisplayTarget.EXTERNAL, settings.displayTarget)
assertEquals(Theme.LIGHT, settings.theme)
assertEquals(1.2, settings.fontScale, 0.0)
assertTrue(settings.highVisibilityMapPlayer)
assertEquals(settings, persisted)
runtime.close()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ data class CompanionSettings(
val overlayScale: Double = 1.0,
val battlePollingIntervalMs: Int = 5,
val mapFollowSmoothingPercent: Int = 25,
val highVisibilityMapPlayer: Boolean = false,
)

data class OwnedPokemon(
Expand Down
8 changes: 4 additions & 4 deletions companion-web/e2e/ui-space-regressions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,11 +197,11 @@ test('Pokédex density uses exact shared row geometry at the Thor viewport', asy
await page.getByRole('button', { name: 'Back' }).click();

await expect(page.locator('.pokedex-screen')).toBeVisible();
expect((await firstRow.boundingBox())?.height).toBe(68);
expect((await firstRow.boundingBox())?.height).toBe(76);
expect(await page.locator('.species-list').evaluate(element => ({
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}))).toMatchObject({ scrollHeight: 80 * 68 });
}))).toMatchObject({ scrollHeight: 80 * 76 });

await page.getByRole('button', { name: 'Settings' }).click();
await page.getByRole('button', { name: 'Accessibility' }).click();
Expand All @@ -213,14 +213,14 @@ test('Pokédex density uses exact shared row geometry at the Thor viewport', asy
const enlargedRows = page.locator('.species-row');
const firstEnlargedRow = await enlargedRows.nth(0).boundingBox();
const secondEnlargedRow = await enlargedRows.nth(1).boundingBox();
expect(firstEnlargedRow?.height).toBe(92);
expect(firstEnlargedRow?.height).toBe(103);
expect(firstEnlargedRow!.y + firstEnlargedRow!.height).toBeLessThanOrEqual(secondEnlargedRow!.y);
const enlargedContentExtent = await enlargedRows.nth(0).evaluate(element => ({
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
}));
expect(enlargedContentExtent.scrollHeight).toBe(enlargedContentExtent.clientHeight);
expect(await page.locator('.species-list').evaluate(element => element.scrollHeight)).toBe(80 * 92);
expect(await page.locator('.species-list').evaluate(element => element.scrollHeight)).toBe(80 * 103);
});

test('shared tabs and route headings keep one keyboard focus path at the Thor viewport', async ({ page }) => {
Expand Down
2 changes: 1 addition & 1 deletion companion-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const emptyState: State = {
filter: 'ALL',
selectedAreaId: null,
battleTab: 'ENTRY',
settings: { knowledgeMode: 'ORGANIC', attackEnabled: true, rarityEnabled: true, movesEnabled: true, fontScale: 1, density: 'AUTO', highContrast: false, autoOpenTarget: true, ruleset: 'AUTO', theme: 'GAME', displayTarget: 'AUTO', mapFollowSmoothingPercent: 25 },
settings: { knowledgeMode: 'ORGANIC', attackEnabled: true, rarityEnabled: true, movesEnabled: true, fontScale: 1, density: 'AUTO', highContrast: false, autoOpenTarget: true, ruleset: 'AUTO', theme: 'GAME', displayTarget: 'AUTO', mapFollowSmoothingPercent: 25, highVisibilityMapPlayer: false },
speciesState: {}, observedMoves: {}, battle: null, catalogReady: false, catalogName: null, error: null,
trainer: null, party: [],
activeRulesetId: null, rulesetAssumed: true, loading: { active: false, phase: 'IDLE', completedUnits: 0, totalUnits: 0 },
Expand Down
24 changes: 24 additions & 0 deletions companion-web/src/layoutStyles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,30 @@ describe('screen layout containment', () => {
expect(styles).toMatch(/\.header-destination-action\.party-action \.party-ball-button-ring\s*\{[^}]*fill\s*:\s*var\(--semantic-selected-bg\)/)
})

it('gives compact Pokédex cards enough height for identity and type labels', () => {
const compactRowRule = styles.match(/\[data-density="compact"\] \.species-row\s*\{([^}]*)\}/)?.[1]

expect(compactRowRule).toMatch(/min-height\s*:\s*76px/)
})

it('keeps the compact Party experience bar thick and visibly blue against gray', () => {
const trackRule = styles.match(/\.party-exp-track\s*\{([^}]*)\}/)?.[1]
const fillRule = styles.match(/\.party-exp-fill\s*\{([^}]*)\}/)?.[1]
const compactRule = styles.match(/@media \(max-width: 650px\)[\s\S]*?\.party-exp-track\s*\{([^}]*)\}/)?.[1]

expect(trackRule).toMatch(/border\s*:\s*1px solid #36495d/)
expect(trackRule).toMatch(/background\s*:\s*#d8dde2/)
expect(fillRule).toMatch(/linear-gradient\(90deg, #0b6fbe, #2ca7f0\)/)
expect(compactRule).toMatch(/height\s*:\s*7px/)
})

it('keeps the Local player pulse behind the Accessibility preference', () => {
expect(styles).toMatch(/\.map-marker\.is-current:not\(\.atlas-location-marker\):not\(\.map-player-marker\)/)
expect(styles).toMatch(/\.map-player-marker\.is-high-visibility\s*\{[^}]*animation\s*:\s*current-map-point/)
expect(styles).toMatch(/\.map-player-marker\.has-sprite img\s*\{[^}]*filter\s*:\s*none/)
expect(styles).toMatch(/\.map-player-marker\.has-sprite\.is-high-visibility img\s*\{[^}]*drop-shadow/)
})

it('keeps Party card copy inside the full padded card height', () => {
const copyRule = styles.match(/\.party-slot-copy\s*\{([^}]*)\}/)?.[1]

Expand Down
1 change: 1 addition & 0 deletions companion-web/src/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ export interface Settings {
overlayScale?: number;
battlePollingIntervalMs?: number;
mapFollowSmoothingPercent?: number;
highVisibilityMapPlayer?: boolean;
}

export interface SpeciesState {
Expand Down
16 changes: 16 additions & 0 deletions companion-web/src/pages/MapPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -512,11 +512,27 @@ describe('optional local map presentation', () => {
expect(playerMarker?.getAttribute('aria-label')).toBe('Player position 12, 7');
expect(playerMarker?.classList.contains('atlas-location-marker')).toBe(false);
expect(playerMarker?.classList.contains('has-sprite')).toBe(false);
expect(playerMarker?.classList.contains('is-high-visibility')).toBe(false);
expect(playerMarker?.querySelector('.map-player-dot')).not.toBeNull();
expect(screen.queryByRole('button', { name: 'Show Atlas' })).toBeNull();
expect(screen.queryByRole('button', { name: 'Show Local map' })).toBeNull();
});

it('adds the player pulse only when the Accessibility preference is enabled', () => {
const { container } = render(<MapPage
catalog={localCatalog}
state={{
...state,
currentMapPosition: { x: 12, y: 7 },
settings: { ...state.settings, highVisibilityMapPlayer: true },
}}
onOpenPokedex={vi.fn()}
onOpenSettings={vi.fn()}
/>);

expect(container.querySelector('.map-player-marker')?.classList.contains('is-high-visibility')).toBe(true);
});

it('uses the gender-selected ROM overworld sprite at its ROM-derived native map scale', () => {
const bounds = {
x: 0, y: 0, top: 0, right: 1240, bottom: 825, left: 0,
Expand Down
2 changes: 1 addition & 1 deletion companion-web/src/pages/MapPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,7 @@ export function MapPage({ catalog, state, onOpenPokedex, onOpenSettings, onUpdat
><span /></button>;
})}
{playerPosition && <span
class={`map-marker map-player-marker is-current ${playerMapSpriteUrl ? 'has-sprite' : 'is-fallback'}`}
class={`map-marker map-player-marker is-current ${playerMapSpriteUrl ? 'has-sprite' : 'is-fallback'} ${state.settings.highVisibilityMapPlayer ? 'is-high-visibility' : ''}`}
style={{
left: `${((playerPosition.sceneX + 0.5) / activeMap.gridWidth) * 100}%`,
top: `${((playerPosition.sceneY + 0.5) / activeMap.gridHeight) * 100}%`,
Expand Down
12 changes: 6 additions & 6 deletions companion-web/src/pages/PokedexBrowse.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -429,13 +429,13 @@ describe('Pokédex knowledge modes', () => {
it('shares density row geometry across virtualization and scroll rebasing', () => {
expect(pokedexRowHeight('AUTO')).toBe(94);
expect(pokedexRowHeight('COMFORTABLE')).toBe(94);
expect(pokedexRowHeight('COMPACT')).toBe(68);
expect(pokedexRowHeight('COMPACT', 1.35)).toBe(92);
expect(pokedexRowHeight('COMPACT')).toBe(76);
expect(pokedexRowHeight('COMPACT', 1.35)).toBe(103);
expect(pokedexRowHeight('COMFORTABLE', 1.35)).toBe(127);
expect(pokedexRowHeight('COMPACT', 0.85)).toBe(68);
expect(pokedexRowHeight('COMPACT', 0.85)).toBe(76);
expect(() => pokedexRowHeight('COMPACT', 0)).toThrow(/font scale/i);

for (const rowHeight of [94, 68]) {
for (const rowHeight of [94, 76]) {
for (const scrollTop of [0, rowHeight * 400, Number.MAX_SAFE_INTEGER]) {
const window = pokedexVirtualWindow(900, 1, scrollTop, 254, rowHeight);
const mountedRows = window.endIndex - window.startIndex;
Expand All @@ -446,8 +446,8 @@ describe('Pokédex knowledge modes', () => {
expect(end.paddingBottom).toBe(0);
}

expect(rebasePokedexScrollTop(94 * 123 + 47, 94, 68, 900 * 68 - 254)).toBe(68 * 123 + 34);
expect(rebasePokedexScrollTop(94 * 899, 94, 68, 900 * 68 - 254)).toBe(900 * 68 - 254);
expect(rebasePokedexScrollTop(94 * 123 + 47, 94, 76, 900 * 76 - 254)).toBe(76 * 123 + 38);
expect(rebasePokedexScrollTop(94 * 899, 94, 76, 900 * 76 - 254)).toBe(900 * 76 - 254);
});

it('keeps a nine-hundred-entry catalog within sixty mounted rows while preserving counts and search', () => {
Expand Down
2 changes: 1 addition & 1 deletion companion-web/src/pages/PokedexBrowse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { Catalog, EncounterWindow, State } from '../models';
import { Header, maskIdentityName, PokedexAvatar, speciesIdentityKnowledge, StatusMarks, TypeChip, uniqueTypeIds } from '../components';

const DEFAULT_SPECIES_ROW_HEIGHT = 94;
const COMPACT_SPECIES_ROW_HEIGHT = 68;
const COMPACT_SPECIES_ROW_HEIGHT = 76;
const MAX_MOUNTED_SPECIES = 60;
const OVERSCAN_ROWS = 3;

Expand Down
39 changes: 39 additions & 0 deletions companion-web/src/pages/PokedexDetailNavigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,45 @@ describe('Pokédex evolution navigation', () => {
expect(openAtlas).toHaveBeenCalledOnce();
});

it('does not offer Atlas when the species has no catalogued habitat', () => {
render(<PokedexDetail
catalog={{
...catalog,
areas: catalog.areas.map(area => ({ ...area, speciesIds: [] })),
}}
state={state}
send={vi.fn()}
tab="AREA"
setTab={vi.fn()}
openMove={vi.fn()}
openAbility={vi.fn()}
openAtlas={vi.fn()}
/>);

expect(screen.getByText('NO HABITAT MAP')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'OPEN ATLAS' })).toBeNull();
});

it('does not offer Atlas before Organic mode has observed a known habitat', () => {
render(<PokedexDetail
catalog={catalog}
state={{
...state,
settings: { ...state.settings, knowledgeMode: 'ORGANIC' },
observedAreaBaseIdsBySpecies: {},
}}
send={vi.fn()}
tab="AREA"
setTab={vi.fn()}
openMove={vi.fn()}
openAbility={vi.fn()}
openAtlas={vi.fn()}
/>);

expect(screen.getByText('NO KNOWN LOCATIONS')).toBeTruthy();
expect(screen.queryByRole('button', { name: 'OPEN ATLAS' })).toBeNull();
});

it('keeps AREA safe when the catalog has no normalized map', () => {
render(<PokedexDetail
catalog={{ ...catalog, worldMaps: [] }}
Expand Down
10 changes: 7 additions & 3 deletions companion-web/src/pages/PokemonAreaMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ export function PokemonAreaMap({ catalog, state, speciesId, send, onOpenAtlas }:
.map(area => area.baseAreaId ?? Math.floor(area.id / 10))), [catalog.areas, speciesId]);
const organic = state.settings.knowledgeMode === 'ORGANIC';
const visibleBaseIds = useMemo(
() => organic ? new Set(state.observedAreaBaseIdsBySpecies?.[speciesId] ?? []) : habitatBaseIds,
() => organic
? new Set((state.observedAreaBaseIdsBySpecies?.[speciesId] ?? [])
.filter(baseAreaId => habitatBaseIds.has(baseAreaId)))
: habitatBaseIds,
[habitatBaseIds, organic, speciesId, state.observedAreaBaseIdsBySpecies],
);
const presentedBaseIds = organic ? visibleBaseIds : habitatBaseIds;
Expand All @@ -25,13 +28,14 @@ export function PokemonAreaMap({ catalog, state, speciesId, send, onOpenAtlas }:
) ?? [], [region?.key, visibleBaseIds]);
const [selectedKey, setSelectedKey] = useState(() => visibleLocations[0]?.key ?? '');
const selected = visibleLocations.find(location => location.key === selectedKey) ?? visibleLocations[0];
const atlasAvailable = onOpenAtlas != null && visibleBaseIds.size > 0;
const fogRef = useRef<HTMLCanvasElement>(null);

useEffect(() => {
if (region && fogRef.current) paintFog(fogRef.current, region, visibleLocations);
}, [region?.key, visibleLocations]);

if (!region) return <div class="pokemon-area-empty"><strong>{organic && habitatBaseIds.size > 0 ? 'NO KNOWN LOCATIONS' : 'NO HABITAT MAP'}</strong><p>{organic && habitatBaseIds.size > 0 ? 'Discover this Pokémon in the wild to reveal its habitat.' : 'No habitat map is available for this game.'}</p>{onOpenAtlas && <button type="button" class="primary-button" onClick={onOpenAtlas}>OPEN ATLAS</button>}</div>;
if (!region) return <div class="pokemon-area-empty"><strong>{organic && habitatBaseIds.size > 0 ? 'NO KNOWN LOCATIONS' : 'NO HABITAT MAP'}</strong><p>{organic && habitatBaseIds.size > 0 ? 'Discover this Pokémon in the wild to reveal its habitat.' : 'No habitat map is available for this game.'}</p>{atlasAvailable && <button type="button" class="primary-button" onClick={() => onOpenAtlas?.()}>OPEN ATLAS</button>}</div>;

return <section class="pokemon-area-panel" aria-label="Pokémon habitat atlas">
<header>
Expand Down Expand Up @@ -61,7 +65,7 @@ export function PokemonAreaMap({ catalog, state, speciesId, send, onOpenAtlas }:
})}
{selected && <button class="pokemon-area-dex" aria-label="Open selected Area Pokédex" onClick={() => send('MAP_AREA', { regionKey: region.key, locationKey: selected.key })}><DexIcon /></button>}
</div>
{visibleLocations.length === 0 && <div class="pokemon-area-undiscovered"><p>No organically observed habitat yet. Undiscovered locations stay masked.</p>{onOpenAtlas && <button type="button" class="primary-button" onClick={onOpenAtlas}>OPEN ATLAS</button>}</div>}
{visibleLocations.length === 0 && <div class="pokemon-area-undiscovered"><p>No organically observed habitat yet. Undiscovered locations stay masked.</p>{atlasAvailable && <button type="button" class="primary-button" onClick={() => onOpenAtlas?.()}>OPEN ATLAS</button>}</div>}
</section>;
}

Expand Down
12 changes: 12 additions & 0 deletions companion-web/src/pages/SettingsPage.production.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,18 @@ describe('production settings copy', () => {
expect(send).toHaveBeenCalledWith('SETTINGS', { mapFollowSmoothingPercent: 70 });
});

it('keeps the enhanced Local player marker off by default and exposes it under Accessibility', () => {
const send = vi.fn();
render(<SettingsPage catalog={catalog} state={state} send={send} onUpload={vi.fn()} initialCategory="ACCESSIBILITY" />);

const toggle = screen.getByRole('checkbox', { name: 'High-visibility map player' });
expect(toggle).toHaveProperty('checked', false);
expect(screen.getByText(/adds a pulsing outline around the Local map player/i)).toBeTruthy();

fireEvent.click(toggle);
expect(send).toHaveBeenCalledWith('SETTINGS', { highVisibilityMapPlayer: true });
});

it('does not expose the retired Thor focus setting or status', () => {
render(<SettingsPage catalog={catalog} state={state} send={vi.fn()} onUpload={vi.fn()} initialCategory="ACCESSIBILITY" />);

Expand Down
Loading
Loading