mirror of
https://github.com/luxfi/quest.git
synced 2026-10-30 15:20:25 +00:00
The repo was a "coming soon" page. This is the game. A quest is a VALUE, not a database row — name, prize and every stop encode into the share link, so making one and sending it needs no account, no server and no storage. The code rides in the URL FRAGMENT, never the query string: a fragment is not sent to the server and does not appear in a Referer header, so the places someone chose stay out of access logs and off any site they click through to. `?q=` would quietly publish every quest anyone shares. Arriving is the one rule worth getting right. A stop is found when the device reports a position inside its radius, and the browser's own accuracy estimate is ADDED to that radius — a phone between tall buildings reports ±50 m, and holding it to the bare radius tells someone standing at the right door they are not there. Wildly inaccurate readings are capped, so a device claiming ±50 km cannot unlock a city from the sofa. Google is confined to one module. With no NEXT_PUBLIC_GOOGLE_MAPS_KEY every stop falls back to a list with a Maps link and the hunt is still playable, and "no key configured" is kept distinct from "Google refused" — collapsing those is how a missing env var gets mistaken for an outage. Prizes are the maker's own words, settled between the two people. Nothing here holds money or hands anything out; an escrow would be a custody problem and saying otherwise in the UI would be a lie about what happens when someone wins. Proven, not asserted: 28 unit tests over the pure core (unicode place names, truncated and hand-edited links returning null instead of throwing, coordinate precision against a 45 m radius, corrupt localStorage) and 7 Playwright tests driving the real static export at phone size with FAKED GPS — walk to Clarion Alley and the point lands, stand three blocks away and it does not, refuse location and the screen says so rather than doing nothing silently. Also fixes a pre-existing break: Tailwind was configured with no stylesheet importing it, so every utility class in the app was inert. Co-authored-by: Hanzo Dev <[email protected]>
149 lines
6.2 KiB
TypeScript
149 lines
6.2 KiB
TypeScript
import { test, expect, type Page } from '@playwright/test'
|
|
|
|
/**
|
|
* The game loop, end to end, in a real browser: build a quest, share the link,
|
|
* walk to a stop, find it.
|
|
*
|
|
* Geolocation is faked by Playwright rather than stubbed in the app, so the
|
|
* production `navigator.geolocation` path and the real arrival maths both run.
|
|
* A unit test cannot prove someone standing at Clarion Alley gets their point;
|
|
* this can.
|
|
*/
|
|
|
|
const CLARION = { latitude: 37.76474, longitude: -122.42201 }
|
|
const BALMY = { latitude: 37.75218, longitude: -122.41213 }
|
|
/** A few blocks off — close enough to be plausible, far enough to be wrong. */
|
|
const NEARBY = { latitude: 37.7689, longitude: -122.4265 }
|
|
|
|
async function buildQuest(page: Page) {
|
|
await page.goto('/create.html')
|
|
await page.getByPlaceholder('Mission murals').fill('Mission murals')
|
|
await page.getByPlaceholder('San Francisco').fill('San Francisco')
|
|
await page.getByPlaceholder(/Coffee at Ritual/).fill('Coffee at Ritual, on me.')
|
|
|
|
const coords = page.getByLabel('Add a stop by coordinates')
|
|
for (const [pt, clue, answer] of [
|
|
[CLARION, 'Where the walls argue back', 'Clarion Alley'],
|
|
[BALMY, 'The oldest paint in the barrio', 'Balmy Alley'],
|
|
] as const) {
|
|
await coords.fill(`${pt.latitude}, ${pt.longitude}`)
|
|
await coords.press('Enter')
|
|
const i = page.locator('li', { hasText: /^Stop/ }).last()
|
|
await i.getByPlaceholder(/^Clue/).fill(clue)
|
|
await i.getByPlaceholder(/^Answer/).fill(answer)
|
|
}
|
|
|
|
const link = await page.getByLabel('Share link').inputValue()
|
|
expect(link).toContain('/play#')
|
|
return link
|
|
}
|
|
|
|
test('a maker builds a quest and the link carries the whole thing', async ({ page }) => {
|
|
const link = await buildQuest(page)
|
|
|
|
// The quest travels in the FRAGMENT — never the query string, so it is not
|
|
// sent to a server and cannot land in an access log.
|
|
const url = new URL(link)
|
|
expect(url.hash.length).toBeGreaterThan(20)
|
|
expect(url.search).toBe('')
|
|
|
|
await expect(page.getByText('20 pts', { exact: false })).toBeVisible()
|
|
})
|
|
|
|
test('a player walks to a stop and finds it', async ({ page, context }) => {
|
|
const link = await buildQuest(page)
|
|
await context.grantPermissions(['geolocation'])
|
|
|
|
// Standing somewhere else in the Mission.
|
|
await context.setGeolocation(NEARBY)
|
|
await page.goto(link.replace(/^https?:\/\/[^/]+/, '') .replace('/play#', '/play.html#'))
|
|
|
|
await expect(page.getByRole('heading', { name: 'Mission murals' })).toBeVisible()
|
|
await expect(page.getByText('0 of 2 found')).toBeVisible()
|
|
// The clue shows; the answer does not, until it is found.
|
|
await expect(page.getByRole('paragraph').filter({ hasText: 'Where the walls argue back' })).toBeVisible()
|
|
await expect(page.getByText('Clarion Alley')).toHaveCount(0)
|
|
|
|
await page.getByRole('button', { name: "I'm here" }).click()
|
|
await expect(page.getByText(/Not there yet/)).toBeVisible()
|
|
await expect(page.getByText('0 of 2 found')).toBeVisible()
|
|
|
|
// Now actually walk there.
|
|
await context.setGeolocation(CLARION)
|
|
await page.getByRole('button', { name: "I'm here" }).click()
|
|
await expect(page.getByText(/Found it/)).toBeVisible()
|
|
await expect(page.getByText('1 of 2 found')).toBeVisible()
|
|
await expect(page.getByText('10/20 pts')).toBeVisible()
|
|
// The answer is revealed only now. Scoped to the stop list: after a find the
|
|
// name legitimately appears three times — the list, the status line, and the
|
|
// map's link — and a loose locator would trip strict mode on correct output.
|
|
await expect(
|
|
page.getByRole('paragraph').filter({ hasText: /^Clarion Alley$/ }),
|
|
).toBeVisible()
|
|
})
|
|
|
|
test('progress survives closing the page', async ({ page, context }) => {
|
|
const link = await buildQuest(page)
|
|
const path = link.replace(/^https?:\/\/[^/]+/, '').replace('/play#', '/play.html#')
|
|
await context.grantPermissions(['geolocation'])
|
|
await context.setGeolocation(CLARION)
|
|
|
|
await page.goto(path)
|
|
await page.getByRole('button', { name: "I'm here" }).click()
|
|
await expect(page.getByText('1 of 2 found')).toBeVisible()
|
|
|
|
await page.reload()
|
|
await expect(page.getByText('1 of 2 found')).toBeVisible()
|
|
})
|
|
|
|
test('finishing every stop reveals the prize', async ({ page, context }) => {
|
|
const link = await buildQuest(page)
|
|
const path = link.replace(/^https?:\/\/[^/]+/, '').replace('/play#', '/play.html#')
|
|
await context.grantPermissions(['geolocation'])
|
|
|
|
await context.setGeolocation(CLARION)
|
|
await page.goto(path)
|
|
await page.getByRole('button', { name: "I'm here" }).click()
|
|
await expect(page.getByText('1 of 2 found')).toBeVisible()
|
|
|
|
await context.setGeolocation(BALMY)
|
|
await page.getByRole('button', { name: "I'm here" }).click()
|
|
|
|
await expect(page.getByRole('heading', { name: 'Quest complete' })).toBeVisible()
|
|
await expect(page.getByText('Coffee at Ritual, on me.')).toBeVisible()
|
|
await expect(page.getByRole('button', { name: 'All found' })).toBeDisabled()
|
|
})
|
|
|
|
test('a mangled link says so instead of breaking', async ({ page }) => {
|
|
const errors: string[] = []
|
|
page.on('pageerror', (e) => errors.push(e.message))
|
|
|
|
await page.goto('/play.html#thisIsNotAQuest')
|
|
await expect(page.getByRole('heading', { name: /not a quest/i })).toBeVisible()
|
|
await expect(page.getByRole('link', { name: /Make your own/ })).toBeVisible()
|
|
expect(errors).toEqual([])
|
|
})
|
|
|
|
test('refusing location is explained, not silently ignored', async ({ page, context }) => {
|
|
const link = await buildQuest(page)
|
|
const path = link.replace(/^https?:\/\/[^/]+/, '').replace('/play#', '/play.html#')
|
|
// Permission NOT granted — the browser will refuse.
|
|
await context.clearPermissions()
|
|
await page.goto(path)
|
|
await page.getByRole('button', { name: "I'm here" }).click()
|
|
|
|
// The one thing this screen must never do is nothing.
|
|
await expect(page.getByText(/blocked|Could not get a location/i)).toBeVisible()
|
|
})
|
|
|
|
test('the whole app renders on a phone with no horizontal scroll', async ({ page }) => {
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
for (const path of ['/index.html', '/create.html', '/play.html#x']) {
|
|
await page.goto(path)
|
|
const overflow = await page.evaluate(
|
|
() => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1,
|
|
)
|
|
expect(overflow, `${path} scrolls sideways on a 390px phone`).toBe(false)
|
|
}
|
|
})
|