24 Commits

Author SHA1 Message Date
c55ee7f47e Merge pull request 'feat(theme): make the app a full consumer of the shard's admin theming and nav (M12 cutover)' (#41) from edge into main
All checks were successful
sync-project-tree / sync (push) Successful in 21s
SonarQube / analysis (push) Successful in 9m39s
Release APK / release (push) Successful in 10m32s
Reviewed-on: #41
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 16:28:50 +00:00
6cbfdb1e65 Merge pull request 'fix(theme): reach Material's default arguments, and measure the theme resolvers (M12 phase 8)' (#40) from chore/m12-phase-8-coverage-and-cutover into edge
All checks were successful
PR Checks / android-build (pull_request) Successful in 11m31s
Reviewed-on: #40
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 16:07:01 +00:00
b84a973559 fix(theme): let the shard's panel color and pill radius reach Material's defaults (M12 phase 8)
Two defects found on device by phase 8's AC-5 walk, both the same trap phase 2
hit with card elevation: Material takes these values as DEFAULT ARGUMENTS, not
from the theme, so mapping the token is not enough on its own.

1. Every ShardCard drew in Material's grey, not the shard's panel color.
   CardDefaults.cardColors() takes its container from surfaceContainerHighest -
   FilledCardTokens.ContainerColor, checked in the material3 1.3.0 artifact's
   bytecode - and shardColorScheme mapped surfaceContainer, High and Low but not
   Highest. All 26 ShardCard sites across 20 files were affected. Themed
   instances showed it worst: on Fantasy the page went brown and the cards
   stayed grey.

   This is NOT an M12 regression. The untouched app draws the same grey cards
   and has since M5; M12 only made it obvious by theming everything around them.
   Fixing it therefore changes the untouched app too - cards move from Material's
   grey to --panel-flat - which is the milestone's second deliberate change to a
   shard that has set nothing, alongside phase 2's card shadow. AC-1 is updated
   to record that rather than absorb it: every other role is still asserted
   byte-for-byte against the verbatim pre-M12 scheme, and the two that moved are
   named, given their new values, and checked to have actually differed before.

   surfaceContainerLowest is mapped alongside it for consistency with
   surfaceContainerLow. It has no reader in this app - the phase 8 sweep checked
   every Material component the app draws against the roles the mapping leaves at
   Material defaults, and surfaceContainerHighest was the only live one. The
   drawer scrim reads the unmapped `scrim`, which stays Material's black
   deliberately.

2. The drawer's selected row ignored --radius-pill. NavigationDrawerItem takes
   `shape` as a default argument (CircleShape); the three call sites set `colors`
   but never `shape`, so on Fantasy every other radius went square while the
   selected row stayed fully round.

Verified on device against a Fantasy-themed local instance: the three ShardCards
on the shard screen now paint --panel-flat, and the selected drawer row is the
4px rectangle the preset asks for.

477 unit tests green (476 + 1), lintDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 11:00:06 -05:00
c14342aa51 chore(sonar): measure the theme resolvers instead of excluding them (M12 phase 8)
sonar.coverage.exclusions carried a ui/theme/** directory glob from the M11
coverage push (COVERAGE_PLAN.md §2 phase 0). At the time that directory held
only Color.kt, Type.kt and the composables, so excluding all of it cost nothing.

M12 put three pure resolvers in it. ShardPalette, ShardStructure and
ShardTypeface are the milestone's core logic, they are the reason phases 1-3
could prove the no-op invariant as a JVM assertion, and JaCoCo on edge measures
them at 98%, 100% and 100%. The directory glob was dropping all of that out of
the denominator, so a future change that deleted those tests would not move the
coverage number at all.

The glob is now the one file it was really about: Theme.kt, the composable
(52%). The rest of ui/theme/ is measured, all of it 93% or better.

This does not rescue the gate - M12's already-measured code (data/appearance/
and ui/navigation/) covers at 93-100% and clears new_coverage >= 50 on its own.
It makes the number honest about which code the tests actually hold.

ui/components/ stays excluded as a directory: BrandAssets.kt is 11%, and the
9 tests it does have are on brandAssetUrl, the one part of it that is not a
composable body.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 10:21:18 -05:00
aeda919376 Merge pull request 'feat(nav): group the drawer into the shard's sections and honor its added links (M12 phase 6)' (#39) from feat/m12-phase-6-nav-sections-links into edge
Reviewed-on: #39
2026-08-08 12:59:53 +00:00
15a4d44c3f feat(nav): group the drawer into the shard's sections and honor its added links (M12 phase 6)
Phase 6 of M12 (docs/android/THEMING_AND_NAV.md §6.3): the drawer gains the
sections an admin grouped rows into and the links they added of their own, the
last of the public nav the website publishes.

buildNavTree ports the web's buildPublicNav and pruneNav; a link's path is
validated by the website's own read rule and resolved through resolveWebPath,
which the app has to answer for any page on the site rather than the nav's
sixteen. A link the app can open natively does; one it cannot hands off to a
Custom Tab, absolute against the configured base URL.

Phase 6 does not re-implement phase 5: with no sections and no links stored,
buildNavTree hands straight to applyNavOverrides, so an untouched instance still
gets APP_MENU back by identity and AC-1's proof is unchanged.

visibleEntries is split into isEntryVisible so pruneNav can apply the same
predicate inside a section, and drop one the gates leave empty.

476 unit tests green (442 + 34); lintDebug and assembleDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 07:49:12 -05:00
fbe8b0bab6 Merge pull request 'feat(nav): honor the shard's public nav order, labels and hiding (M12 phase 5)' (#38) from feat/m12-phase-5-public-nav into edge
Reviewed-on: #38
2026-08-08 12:12:44 +00:00
94a5c26d6c feat(nav): honor the shard's public nav order, labels and hiding (M12 phase 5)
The drawer has been the app's coded `APP_MENU` in coded order since M1. Phase 5
lets an admin's `nav_public` row relabel, reorder and hide its public rows, which
is the first time anything in the app's navigation comes from the shard.

The public nav is keyed by **website** paths, so this needs a translation table,
and it is the one new piece of cross-repo coupling the milestone introduces. It
lives in a single file with the website's own `NAV` array quoted beside it —
`NavPaths.kt` — so the coupling is visible and reviewable in one place instead of
spread across the drawer's call sites. The `feature` values are deliberately not
mirrored: `APP_MENU` stays the app's own source of truth for gating, and a second
copy of a security-relevant value that drifts silently is worth more than it
costs.

Nine of the sixteen website rows have a drawer row. The other seven map to a
screen the app reaches another way — three news categories are tabs on one News
screen, and champs / guilds / governors / houses sit behind the Shard hub because
that is the better shape on a phone — and an override for one of them is
**ignored**, which is §6.1's rule that a nav override may never introduce
navigation. The hub is a design decision, not an accident to correct. The mapping
still exists for all sixteen because phase 6's added links resolve an
admin-authored path against the same table, and there a category tab or a hub
board is a perfectly good destination: the admin asked for it by path.

The merge is a port of the website's `applyNavOverrides`, narrowed to what a
drawer can express — `label`, `order`, `hidden`, and nothing else. It runs
**before** `visibleEntries`, so the two gates from M10/M11 still decide what this
caller may see and remain the actual boundary: an override that relabels the
Market row, moves it to the front and says `hidden: false` still shows nothing to
a caller whose shard does not publish the market. Hiding is subtractive, never
additive.

One thing the design did not settle and the sort turns on: an untouched row's
implicit key has to be its index in the **website's** nav, not the app's. A
stored `order` is a position in that list, so a key taken from the app's shorter
list would put explicit and implicit keys on two incomparable number lines and
scramble a partially-overridden nav. Both tie-breaks are the web's — an explicit
order beats a coincidental index, and two explicit orders keep code order.

`Routes.news(category)` and an optional NavHost argument ship here as the table's
route builder; phase 6 is their first caller. Navigating to plain `Routes.NEWS`
matches the new pattern with no argument and opens the default tab, so the drawer
and the push deep-link are unaffected — but `destination.route` is now a pattern
with a query, so the top-level and selected-row checks compare on the part before
it.

Two questions went to the org lead before any code. The three news-category paths
get a mapped route but no drawer row of their own, on the same rule as the hub
four. And an admin **may** hide Home, mirroring the website, where `/` is
hideable too: Home stays the NavHost's start destination and stays reachable by
back-press, and the app does not invent a policy the site doesn't have.

442 unit tests green (410 + 32), `lintDebug` and `assembleDebug` clean. The
strongest of them is AC-1's: with no stored row the merge returns `APP_MENU`
itself — identity, not equality — so an instance whose admin never touched the
nav provably gets the drawer the app shipped with.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 07:07:10 -05:00
b95fc45548 Merge pull request 'feat(brand): draw the shard's logo and hero (M12 phase 4)' (#37) from feat/m12-phase-4-brand-assets into edge
Reviewed-on: #37
2026-08-08 11:52:03 +00:00
3edd45d5f4 feat(brand): draw the shard's logo and hero (M12 phase 4)
`brand.logo` and `brand.hero` have ridden in `BrandDto` since M1 and neither
has ever been drawn — the app spells the instance out in text everywhere the
website shows a mark. Phase 4 renders them on the three surfaces §5.6 names:
the logo above the name in the drawer header, the logo in place of the
uppercased title in the top bar, and the hero as a band above Home's title
block.

Nothing new is fetched. `LocalAssetResolver` already turns a site-relative
`/uploads/…` path into an absolute URL and Coil is already a dependency, so
this phase is entirely presentation.

The rule that governs the file is §5.6's: an empty slot renders nothing — not
a placeholder, not a reserved gap. Every size modifier hangs off the image
itself, so when the image is not composed neither is its padding, and a caller
that wants space below a hero passes `Modifier.padding` instead of a sibling
`Spacer`. A failed load is an empty slot: no broken-image icon, no retry.

The top bar is the one place where "empty" is not "nothing". The logo replaces
the title there, so a 404 would strand the app in an unnamed shell until the
next resume refresh; it falls back to the text, which is what empty already
showed. There is no fallback while the load is in flight — drawing the text
first would flash text to logo on every navigation for one frame.

The hero is a fixed 180dp band, cropped, rather than the intrinsic aspect the
app's other images draw at. The website's hero is a CSS background driven by
`hero_layout`, which the app does not port, and the website's default hero is a
square emblem — at the intrinsic aspect an uploaded square would be a ~360dp
block that pushes the status card off the first screenful. It clips to
`shapes.medium`, so it follows `--radius-card` like every other surface.

The logo carries a content description only in the top bar, where it stands
alone; beside the name in text it is decorative, the same call the website's
`alt=''` makes.

410 unit tests green (401 + 9), `lintDebug` and `assembleDebug` clean. The
drawing itself is out of reach for JVM tests — the app carries no Robolectric,
so a composable body cannot run — but the decision of *whether* to draw is
pure, and `brandAssetUrl` is pulled out so it can be pinned.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 06:13:16 -05:00
0051e97bc7 Merge pull request 'feat(theme): draw the app in the shard's chosen type families (M12 phase 3)' (#36) from feat/m12-phase-3-fonts into edge
Reviewed-on: #36
2026-08-08 10:51:39 +00:00
a19fdd3582 feat(theme): draw the app in the shard's chosen type families (M12 phase 3)
Phase 3 of M12 (docs/android/THEMING_AND_NAV.md §5.3) — the fonts third of the
admin's Appearance page, after phase 1's colors and phase 2's structure.

ShardTypeface.resolve(theme) maps the three font stacks onto three FontFamily
values and shardTypography(faces) draws the M5 type scale in them. Only the
family moves: every size, weight, line height and tracking is the M5 value, so
an unthemed instance reproduces the pre-M12 scale exactly. Resolution is pure,
so every assertion is a plain JVM test with no Compose rule.

Seven families are bundled beside the existing Cinzel (EB Garamond,
Merriweather, Playfair Display, IM Fell English, Inter, Work Sans,
Source Sans 3), taken verbatim from google/fonts the way M5 took Cinzel, each
with its SIL OFL licence under app/licenses/. Italics for the four families
client/index.html requests one for; the rest are skewed, as they were before.

Three things worth knowing:

1. The per-role font list is not the set of values a role can hold. The server
   validates admin-entered fonts against FONT_OPTIONS[role], but a preset's
   tokens are copied verbatim by resolveThemeTokens and never pass through it —
   `modern` publishes --display: 'Work Sans' and `fantasy` publishes
   --sans: 'EB Garamond', neither of which its own dropdown offers. The lookup
   is therefore one global map keyed by the lowercased first family name, and
   both preset cases are asserted by name so a per-role "tidy-up" fails loudly.
   Same trap phase 2 hit with --shadow-card, in a different token group.

2. The APK nearly tripled, and that was a decision, not a discovery. Measured
   unsigned release, R8 + resource shrink: 5,031,411 B (4.80 MiB) before,
   13,574,703 B (12.94 MiB) after — +8.15 MiB against a drafted estimate of
   1.5-2.5 MB. Merriweather alone is 6.08 MiB of that, because upstream ships
   it as a three-axis [opsz,wdth,wght] variable font that deflates only 31%.
   The org lead chose to bundle it verbatim with the cheaper options costed:
   Google's own static 400/700 builds would have held the app near 6.8 MiB,
   and dropping it near 6.2 MiB at the price of a serif option that silently
   does nothing on Android.

3. Typography implements equals — like phase 2's Shapes, unlike phase 1's
   ColorScheme, checked the same way in the material3 1.3.0 bytecode. AC-1's
   type half is one comparison against a verbatim copy of the pre-M12 scale
   held in the test.

No LocalShardTypeface: unlike the palette and the structure, MaterialTheme
carries the families completely, and the two composables that override
anything override the style rather than the family. Type.kt's `val Typography`
becoming a function is the whole migration — the three families were
referenced from that one file and nowhere else.

Tests: ShardTypefaceTest (15). 401 unit tests green (386 + 15), lintDebug and
assembleDebug clean. Not exercised on device — that is AC-5, in phase 8, where
IM Fell English's synthesised bold is the thing to look at.

Docs: RunicGateway/docs#TBD

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 05:45:31 -05:00
7acbe54f46 Merge pull request 'feat(theme): scale the shard's radii and card depth onto the app's scale (M12 phase 2)' (#35) from feat/m12-phase-2-structure into edge
Reviewed-on: #35
2026-08-08 10:17:39 +00:00
c7c49a9d6b feat(theme): scale the shard's radii and card depth onto the app's scale (M12 phase 2)
The structure half of the admin's Appearance page. ShardStructure.resolve() turns
the four --radius-* tokens and --shadow-card into a Material shape scale, a pill
shape and a card elevation; RunicGatewayTheme feeds the scale to MaterialTheme and
the other two to a LocalShardStructure, mirroring phase 1's palette split.

Radii are applied as a ratio, never as a literal. The app's Shapes came from the
M5 mockup and the website's from theme.css, and the two scales differ - copying
the web value in would have restyled an untouched app on day one. Each field is
scaled by resolved / runic-gateway baseline instead, so the shipped theme and an
explicit runic-gateway both give ratio 1.0 and are provable no-ops.

Three things the spec did not survive contact with:

Card depth is not a no-op, and that is the org lead's decision. Material3's
filled Card is Level0 and FeatureCard drew none of the shadow its own docs
claimed, so the app has been flat since M5 - while the preset it was drawn from
selects the "Default" shadow. Section 5.4 is applied as written rather than
rebased on the flat baseline, which would have collapsed three of the admin's
four choices onto 0dp. Every card gains 4dp; sections 2, 5.4 and AC-1 record it.

The shadow is matched by nearest blur, not by exact string. The fantasy preset
publishes a --shadow-card that SHADOW_OPTIONS does not contain, because a
preset's tokens are copied verbatim and never pass through the admin dropdown -
an exact match would have missed the one preset whose point is a heavier shadow.

--radius-pill is resolved as a literal px, because CircleShape is a percentage
and has no shipped dp for a ratio to scale. It reaches exactly one composable:
the app's other two CircleShape uses are 8dp status dots, and a dot stays a dot.

ShardCard exists because Material's theme cannot carry elevation - Card takes it
as a default argument. All 24 Card( call sites across 20 files moved to the
wrapper, which is mechanical because every one of them passed only a modifier. A
Card( outside ThemeComponents.kt is now, by construction, an unthemable card.

Shapes does implement equals (unlike ColorScheme), so the structural no-op proof
is one assertion against a verbatim copy of the pre-M12 scale. 13 new tests, 386
green, lintDebug and assembleDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 05:15:08 -05:00
1530c83fbc Merge pull request 'feat(theme): resolve the shard's palette into the Material scheme (M12 phase 1)' (#34) from feat/m12-phase-1-colors into edge
Reviewed-on: #34
2026-08-08 09:57:21 +00:00
c65913c62a feat(theme): resolve the shard's palette into the Material scheme (M12 phase 1)
The fifteen themable tokens of GET /public/settings' theme map are parsed into
a ShardPalette and applied field by field over the shipped M5 palette, which is
the runic-gateway preset value for value — so an instance with no theme_visual
row resolves back to a color scheme identical to the one the app shipped, not
an approximation of it (THEMING_AND_NAV.md §2, §5.1).

Ten tokens have a Material role and go through darkColorScheme; the other five
reach screens through LocalShardPalette. ShardOnCta and ShardPillFg are derived
rather than themed — they track --bg-deep and --accent-bright, following the
server's rule that a value expressed in terms of another token is never frozen
as a literal.

RunicGatewayTheme(accent) becomes RunicGatewayTheme(appearance). The old
signature put --accent on primary, which the contract assigns to
--accent-bright; brand.accent now seeds --accent alone, and the server already
resolves it as theme['--accent'] || env so the two can never disagree.

ThemeComponents.kt was the only file reaching past MaterialTheme.colorScheme
for a themable color; its seven now come from the palette and its seven
semantic constants stay imported.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 04:53:23 -05:00
17e9451494 Merge pull request 'feat(appearance): read the admin theme and nav contract into a SiteAppearance (M12 phase 0)' (#33) from feat/m12-phase-0-appearance-store into edge
Reviewed-on: #33
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-08 07:01:42 +00:00
b0117acac1 feat(appearance): read the admin theme and nav contract into a SiteAppearance (M12 phase 0)
The app has been reading exactly one field of the website's admin theming
contract -- brand.accent. This lands the store the rest of M12 builds on:
GET /public/settings' `theme` (the resolved token map) and `nav_public` (the
raw nav override row) are now decoded, coerced and held beside the brand as
one SiteAppearance, refreshed on resume alongside the session re-validation.

Nothing reads the two new fields yet. Phase 0's hard rule is that it must
change nothing on screen, so RunicApp still takes `brand: BrandDto?` and the
theme is still seeded from the accent alone; AppState.Ready is the only place
a type changed.

Two judgement calls, both in service of THEMING_AND_NAV.md section 2's
forgiving-on-read rule:

- `theme` is modeled as a raw JsonElement rather than Map<String,String>?.
  kotlinx fails the decode of the whole object on a value of an unexpected
  kind, and `theme` shares its payload with `brand` and `push` -- one odd
  token would have blanked the branding and dropped the push relay URL. It is
  coerced field-by-field instead, so a bad token costs exactly itself.
- A failed *refresh* keeps the last good appearance rather than falling back
  to NONE. Only the initial load can produce NONE, so a moment of no
  connectivity on resume cannot repaint a themed shard back to the defaults.

The second-stage parse stops at "is this a plain object", mirroring the web
client's lib/settingsJson.js exactly; reading items/sections/links out of it
is phases 5 and 6's job, so no half-built nav model ships here.

Tests: SettingsJsonTest (6) and SiteAppearanceTest (8) cover the two pure
modules, plus three decode cases in PublicDtoTest for the wire shapes.
360 unit tests green; lintDebug and assembleDebug clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-08 01:57:45 -05:00
5eaf5d22c6 Merge pull request 'feat(shard): follow the visibility framework and read the Protocol 3.0 profile' (#30) from feat/protocol-3-visibility into main
All checks were successful
sync-project-tree / sync (push) Successful in 20s
SonarQube / analysis (push) Successful in 5m14s
Release APK / release (push) Successful in 10m13s
Reviewed-on: #30
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
2026-08-01 07:22:05 +00:00
12b2172731 Merge pull request 'fix(shard): decode the atlas places objects and render them' (#32) from fix/atlas-places-decode into feat/protocol-3-visibility
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m22s
Reviewed-on: #32
2026-08-01 06:03:51 +00:00
4f85021be2 fix(shard): decode the atlas places objects and render them
`AtlasCreatureDto.places` was typed `List<String>` while the server sends
`{facet, label, spawners, maxAlive}` objects. The detail route answers 200 with
~49 KB, kotlinx throws on decode, and the screen renders "Something went wrong
on the server" — so the whole Atlas creature page was dead, and the error
blamed a server that was fine. Nullable-with-defaults protects against a
missing field, never a wrong element type.

Adds AtlasPlaceDto, plus the `art` field the server also sends, so a decode
cannot depend on that staying absent (neither client renders art yet).

`places` was never rendered either, so the aggregate the atlas exists to give —
"Shrines, Isamu-Jima, Yew", resolved server-side by point-in-rect — was missing
from the app while the web page led with it. Adds a "Where it spawns" section
above the individual spawners, matching web's ordering, and a plural for the
spawner count now that single-spawner places are on screen in bulk.

Adds ShardContentDtoTest — the first decode test any of the four Protocol 3.0
DTOs has had, fed payloads captured from a live server. That absence is the
root cause: the fakes in data/api/fake/ construct DTOs in Kotlin, so no test in
the suite could see a wire mismatch, even though PLAN.md §9 already required
"DTO decode for each new shape".

Also renders a placeholder row on an unscored leaderboard (the instance name,
em dash where a score goes) rather than a blank card — deliberately not shaped
like a real entry, since a placeholder that looked like a standing would be a
fabricated one.

Found by the on-device five-rung walk against a live shard; all four screens
re-verified on the emulator afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U7CBg11prhLimL9iHSX1bP
2026-08-01 00:59:23 -05:00
06b6b015c2 Merge pull request 'feat(shard): the four Protocol 3.0 content screens' (#31) from feat/protocol-3-screens into feat/protocol-3-visibility
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m26s
Reviewed-on: #31
2026-07-30 07:54:27 +00:00
aacef35def feat(shard): the four Protocol 3.0 content screens
M11 Part 2 (docs/android/PLAN.md §9), on the visibility plumbing Part 1 added.
Each screen hides from the menu when the shard doesn't publish its feature, and
self-reports "not available here" from its own 404/403 so a deep link still
lands on an honest answer.

  - Rules (/public/shard/ruleset). A null body means the shard has never
    published a ruleset, which is a SUCCESS state, not the feature being off —
    the screen tells the two apart. Blocks render only when published, since an
    omitted block means the system is off rather than unknown. Skill caps are
    converted out of tenths; the raw 1000 reads as ten times the real limit.
    Live via world.ruleset, which the shard re-emits on every reconnect.
  - Leaderboards (/public/shard/points). Boards order most-contested first, live
    via points.board. maxPoints 0 is uncapped so no cap line is drawn, and a
    cliloc-named board (nameString null, the usual case) falls back to the
    humanised PointsType key. A nameless rank is a valid row: the character name
    is the feature's one admin-configurable field.
  - Market (/public/shard/market + /meta + /vendors/:serial). NOT live: the
    market feature ships with its SSE fan-out disabled, so this is a plain
    paginated read, searched on submit rather than per keystroke because it is
    the site's first rate-limited public endpoint. The staleness line is
    required, not decoration — the round-robin sweep means a price can be a full
    cycle old. The vendor screen is the only surface that can render a truncated
    shop and a gated location, the latter as a real answer rather than a blank
    coordinate.
  - Atlas (/public/atlas/creatures[/:slug]). Static shard content, so it stays
    readable while the shard is down — but site-mode gated, unlike /shard/*.
    Rows lead with the server's placement label ("Despise, Felucca"), which is
    the transform the whole feature exists for. Respawn delays are read as
    SECONDS, the unit the parser normalises XmlSpawner's mixed minutes/seconds
    into. Facet filter options are discovered from the shard's own data — nothing
    here names a facet, since a shard may add, replace or rename them.

336 unit tests pass (32 new); lint clean. The five-rung on-device walk runs
against a local website on the cutover branch before the cutover merges.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 02:50:19 -05:00
833e51de69 feat(shard): follow the visibility framework and read the Protocol 3.0 profile
All checks were successful
PR Checks / android-build (pull_request) Successful in 6m20s
M11 Part 1 (docs/android/PLAN.md §9). The website's Protocol 3.0 work made every
shard-derived surface admin-configurable — a feature can be switched off, or its
audience raised above the caller's rung — and the app knew nothing about it: it
gated shard navigation on the session role alone, so an admin change left the
drawer and the hub offering entries that 404/403 into a generic error where the
web client hides them.

The visibility rules:

  - GET /public/shard/features behind a singleton ShardFeaturesRepository,
    re-resolved on every session change (the answer is per-viewer) and dropped on
    a Settings → Server switch, which is the one case no session change covers.
  - MenuEntry gains `feature` beside `access`; the two gates are independent and
    both must pass. ShardBoard tags each hub tile the same way.
  - An unknown answer FAILS OPEN, matching lib/useShardFeatures.js: the server
    gates every call regardless, so a link that briefly 403s beats a drawer that
    flickers its entries in on every cold start. A pre-3.0 website 404s this
    route, which reads as "unknown" and behaves exactly as before.
  - toShardUiState() maps 404 AND 403 to a new ErrorKind.FEATURE_UNAVAILABLE:
    requireFeature answers 404 for a disabled feature (deliberately not
    disclosing it exists) and 403 for a viewer below its rung. Kept separate from
    toUiState() because both statuses mean something else off the shard surface —
    a deleted post, an ownership refusal. That state renders without a retry
    button; an admin controls it, so retrying cannot change the answer.

The read-model adds, from the same v3 series:

  - char.profile `points` — the Loyalty & Points block. maxPoints 0 means
    UNCAPPED and is the common case, so nothing divides by it and only a capped
    system gets a meter; nameString is usually null (systems name themselves with
    a cliloc) so humanising the PointsType key is the primary display path; rank
    is absent unless the shard opts in, and absent is not "unranked".
  - Cliloc-resolved names — equipment `clilocName` and titles `rewardResolved`,
    so items stop rendering as a layer. rewardResolved is positional: an entry
    the table could not resolve is null and is skipped WITHOUT shifting the
    `selected` index onto its neighbour.

ActorDto keeps acct/webId but documents them as admin-locked rather than
available. Points ride ungated on /player/shard/char/:serial — a character's own
standings are self-service and do not depend on the public leaderboards feature,
so the app mirrors that rather than re-gating it.

304 unit tests pass; lint clean.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-30 02:34:41 -05:00
105 changed files with 8094 additions and 245 deletions

3
.gitattributes vendored
View File

@@ -17,3 +17,6 @@ gradlew text eol=lf
*.png binary
*.webp binary
*.ico binary
# Bundled type families (res/font). `text=auto` already detects these as binary,
# but a font is too easy to corrupt silently to leave to a heuristic.
*.ttf binary

View File

@@ -0,0 +1,93 @@
Copyright 2017 The EB Garamond Project Authors (https://github.com/octaviopardo/EBGaramond12)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright (c) 2010, Igino Marini (mail@iginomarini.com)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright 2020 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair-Display), with Reserved Font Name "Playfair Display"
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright 2010-2020 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -0,0 +1,93 @@
Copyright 2019 The Work Sans Project Authors (https://github.com/weiweihuanghuang/Work-Sans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

View File

@@ -23,6 +23,7 @@ import androidx.compose.ui.Modifier
import com.runicgateway.app.core.auth.sso.SsoAuthManager
import com.runicgateway.app.core.push.PushNotifier
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.LifecycleResumeEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.ui.AppViewModel
import com.runicgateway.app.ui.AppViewModel.AppState
@@ -30,8 +31,8 @@ import com.runicgateway.app.ui.LocalAssetResolver
import com.runicgateway.app.ui.RunicApp
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.connect.ConnectScreen
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.ui.theme.RunicGatewayTheme
import com.runicgateway.app.ui.theme.parseBrandColor
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -39,8 +40,8 @@ import javax.inject.Inject
/**
* Single-activity host (PLAN.md §2). Gates on [AppViewModel]: the first-run
* connect screen until a shard site is configured (§3), then the main app.
* The Material theme is seeded from the per-shard brand accent, and asset-path
* resolution is provided to the whole tree.
* The Material theme is resolved from the shard's published appearance (M12),
* and asset-path resolution is provided to the whole tree.
*/
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@@ -69,9 +70,21 @@ class MainActivity : ComponentActivity() {
val appViewModel: AppViewModel = hiltViewModel()
val state by appViewModel.state.collectAsStateWithLifecycle()
val accent = (state as? AppState.Ready)?.brand?.let { parseBrandColor(it.accent) }
// The whole theme, not just the accent (THEMING_AND_NAV.md §5.1): the
// resolved token map is applied field by field over the shipped palette,
// so NONE — before the site is connected, or when settings can't be
// read — is the app exactly as it shipped.
val appearance = (state as? AppState.Ready)?.appearance ?: SiteAppearance.NONE
RunicGatewayTheme(accent = accent) {
// The admin's theme and nav can change while the app is backgrounded
// (THEMING_AND_NAV.md §5.5). Re-read them on resume, beside the session
// re-validation RunicApp already does. Best-effort and silent.
LifecycleResumeEffect(Unit) {
appViewModel.refreshAppearance()
onPauseOrDispose { }
}
RunicGatewayTheme(appearance = appearance) {
CompositionLocalProvider(LocalAssetResolver provides appViewModel::resolveAsset) {
Surface(
modifier = Modifier.fillMaxSize(),
@@ -83,7 +96,7 @@ class MainActivity : ComponentActivity() {
ConnectScreen(onConnected = appViewModel::onConnected)
is AppState.Ready ->
RunicApp(
brand = s.brand,
appearance = s.appearance,
onChangeServer = appViewModel::changeServer,
deepLinkStream = pendingStream,
onDeepLinkConsumed = { pendingStream = null },

View File

@@ -3,6 +3,9 @@
*/
package com.runicgateway.app.data.api
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
import com.runicgateway.app.data.api.dto.AtlasMetaDto
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.api.dto.ContactRequest
import com.runicgateway.app.data.api.dto.ContactResponse
@@ -12,11 +15,17 @@ import com.runicgateway.app.data.api.dto.GovernorDto
import com.runicgateway.app.data.api.dto.GovernorTermDto
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.api.dto.MarketMetaDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiCategoryDto
@@ -93,6 +102,14 @@ interface PublicApi {
suspend fun postContact(@Body body: ContactRequest): ContactResponse
// ── Public shard widgets (§6.2) ──────────────────────────────────────
/**
* Which shard features this caller may reach, so the menu hides entries instead
* of rendering links that 404/403 (§5, M11). Answered per-viewer: an anonymous
* call and a signed-in one can differ.
*/
@GET("api/v1/public/shard/features")
suspend fun getShardFeatures(): ShardFeaturesDto
@GET("api/v1/public/shard/status")
suspend fun getShardStatus(): ShardStatusDto
@@ -128,4 +145,65 @@ interface PublicApi {
@GET("api/v1/public/shard/houses")
suspend fun getShardHouses(): List<HouseDto>
// ── Protocol 3.0 shard content (§9 M11) ──────────────────────────────
//
// Each of these sits behind the website's `requireFeature` gate: a 404 means the
// shard doesn't publish it and a 403 means this viewer is below its audience rung,
// which `toShardUiState()` folds into one "not available here" state.
/** The shard's configured ruleset. A `null` body means "not published yet". */
@GET("api/v1/public/shard/ruleset")
suspend fun getShardRuleset(): RulesetDto?
/** Every points/loyalty leaderboard the shard publishes. */
@GET("api/v1/public/shard/points")
suspend fun getShardPoints(): List<PointsBoardDto>
@GET("api/v1/public/shard/points/{system}")
suspend fun getShardPointsBoard(@Path("system") system: String): PointsBoardDto
/**
* Search the player-vendor index. **Rate-limited** — the first genuinely expensive
* public endpoint on the site, so handle `429` (`ErrorKind.RATE_LIMITED`).
*/
@GET("api/v1/public/shard/market")
suspend fun getShardMarket(
@Query("q") query: String? = null,
@Query("minPrice") minPrice: Long? = null,
@Query("maxPrice") maxPrice: Long? = null,
@Query("map") map: String? = null,
@Query("region") region: String? = null,
@Query("sort") sort: String? = null,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
): MarketPageDto
/** Index size, staleness, and which facets/regions actually hold vendors. */
@GET("api/v1/public/shard/market/meta")
suspend fun getShardMarketMeta(): MarketMetaDto
@GET("api/v1/public/shard/market/vendors/{serial}")
suspend fun getShardMarketVendor(
@Path("serial") serial: String,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
): MarketVendorDto
// The atlas lives under /public/atlas, NOT /public/shard: it is static shard
// content parsed from the server's data files, so it stays readable while the
// shard is down — but it IS site-mode gated, unlike the shard routes.
@GET("api/v1/public/atlas/creatures")
suspend fun getAtlasCreatures(
@Query("q") query: String? = null,
@Query("facet") facet: String? = null,
@Query("limit") limit: Int? = null,
@Query("offset") offset: Int? = null,
): AtlasCreaturePageDto
@GET("api/v1/public/atlas/creatures/{slug}")
suspend fun getAtlasCreature(@Path("slug") slug: String): AtlasCreatureDto
@GET("api/v1/public/atlas/meta")
suspend fun getAtlasMeta(): AtlasMetaDto
}

View File

@@ -83,8 +83,47 @@ data class CharProfileDto(
val titles: TitlesDto? = null,
val guild: GuildRefDto? = null,
val governorOf: List<String> = emptyList(),
/**
* Loyalty / points standings (Protocol 3.0 §7.3). Empty for a character that has
* earned nothing anywhere — the shard omits systems the character has no entry in
* — and empty on a shard whose plugin predates 3.0.
*
* Served **ungated**: a character's own standings are self-service data on
* `/player/shard/char/:serial` and do not depend on the public `leaderboards`
* feature being visible. Don't re-gate them app-side.
*/
val points: List<CharPointsDto> = emptyList(),
)
/**
* One point system a character holds a score in (Protocol 3.0 §7.3).
*
* Three shapes here are counter-intuitive, and all three are what a REAL shard sends
* (`docs/link/v3.md` §7.5 — a fake shard emits whatever the spec says it should):
*
* - **[maxPoints] `0` means UNCAPPED, and is the common case**, not an edge case.
* ServUO's idiom for an uncapped system is `double.MaxValue`, which the plugin
* normalises to `0` because the C# cast is unchecked and yielded `long.MinValue`.
* Nothing may divide by it, and a full-width progress bar for an uncapped score
* would imply a completion that doesn't exist.
* - **[nameString] is usually `null`.** Most systems name themselves with a cliloc
* rather than a literal, so humanising [system] (`QueensLoyalty` → "Queens
* Loyalty") is the PRIMARY display path, not a defensive fallback.
* - **[rank] is absent unless the shard runs `Bridge.cfg PointsProfileRank=true`.**
* Absent and "unranked" are different answers, so it renders only when sent.
*/
@Serializable
data class CharPointsDto(
val system: String? = null,
val nameString: String? = null,
val points: Long? = null,
val maxPoints: Long? = null,
val rank: Int? = null,
) {
/** The cap, or null when the system is uncapped (see [maxPoints]). */
val cap: Long? get() = maxPoints?.takeIf { it > 0 }
}
@Serializable
data class CharStatsDto(
val str: Int? = null,
@@ -134,17 +173,45 @@ data class EquipmentDto(
val itemId: Int? = null,
val hue: Int? = null,
val mods: JsonObject? = null,
)
/**
* A player-given name — set for the minority of items someone has renamed, null
* for almost everything else. The shard sends the plain `Item.Name` field; it
* never builds a display name (that call is a packet builder, not a field read).
*/
val name: String? = null,
/**
* The item's type name, resolved from its cliloc id **by the website** against
* its own table (`docs/website/CLILOCS.md`). Null on a shard that has no cliloc
* table configured, which is fully supported — the sheet then falls back to the
* layer, exactly as it did before the table existed.
*/
val clilocName: String? = null,
) {
/**
* What to call this item.
*
* A player-given [name] outranks the resolved type name — "Bob's lucky axe" must
* not be relabelled "hatchet" — and the server applies the same precedence, so
* this only re-states it for an item that arrived with both.
*/
val label: String? get() = name ?: clilocName ?: layer
}
/**
* Display titles (Protocol 2.0). `selected` is the index into `reward` currently
* shown (-1 if none); `reward` entries may be a cliloc number-as-string or a
* literal — numeric ones are skipped without a cliloc table (as the website does).
* Display titles (Protocol 2.0). `selected` is the index into [reward] currently
* shown (-1 if none); [reward] entries may be a cliloc number-as-string or a literal.
*
* [rewardResolved] is the website's **parallel array** with the numeric entries turned
* into words against its cliloc table — same length and order as [reward], with a null
* where an id resolved to nothing. It is absent entirely when no entry was numeric or
* the shard has no cliloc table, so read it positionally and tolerate it being short.
* See `displayTitles` in the character sheet.
*/
@Serializable
data class TitlesDto(
val selected: Int? = null,
val reward: List<String> = emptyList(),
val rewardResolved: List<String?> = emptyList(),
val fameKarma: String? = null,
val skill: String? = null,
)

View File

@@ -5,6 +5,7 @@ package com.runicgateway.app.data.api.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
/**
* DTOs for the public site/identity endpoints. Shapes mirror the backend
@@ -80,4 +81,28 @@ data class SettingsDto(
val brand: BrandDto = BrandDto(),
/** Push relay config (M7); default (null ntfyUrl) on a backend that predates it. */
val push: PushConfigDto = PushConfigDto(),
/**
* The admin's **resolved** theme tokens — the CSS custom properties the site
* paints, already layered `:root ← preset ← custom` by the server
* (THEMING_AND_NAV.md §3). Absent when no `theme_visual` row exists, which
* means "the shipped defaults" and is the untouched-instance path.
*
* Held as a raw [JsonElement] rather than a `Map<String, String>` on
* purpose: a single unexpected value must not fail the decode of the whole
* settings payload and take `brand` and `push` down with it. It is coerced
* field-by-field by `SiteAppearance.from`.
*
* The raw `theme_visual` / `brand_assets` rows ride along in this same
* response and are deliberately **not** modeled — they are inputs, and
* re-deriving a palette from them would be a second `resolveThemeTokens` in
* Kotlin, guaranteed to drift (§3).
*/
val theme: JsonElement? = null,
/**
* The public nav overrides, as the raw JSON **string** stored in
* `settings.value` (TEXT) — so it is parsed a second time, exactly as the web
* client's `parseJsonSetting` does. Absent when the admin never edited the
* nav.
*/
@SerialName("nav_public") val navPublic: String? = null,
)

View File

@@ -0,0 +1,367 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.Serializable
/**
* DTOs for the four shard-content surfaces Protocol 3.0 added (PLAN.md §9 M11):
* the ruleset, the points leaderboards, the player-vendor marketplace, and the spawn
* atlas. Shapes mirror the website's `public/shard.controller.js` + `public/atlas.
* controller.js` responses; see `docs/link/v3.md` §5§8.
*
* Every field is nullable-with-a-default, which is load-bearing rather than merely
* defensive here: an admin can gate individual fields away per audience rung
* (`ownerName`, `location`, a board's `name`), so a response legitimately arrives
* with them missing and must still decode.
*/
// ── Ruleset (§5) ────────────────────────────────────────────────────────────
/**
* `GET /public/shard/ruleset` — what this shard's world is configured to do.
*
* Every block is optional and omitted when its system is off, so a null block means
* "not applicable here", not "unknown". A `null` BODY (rather than an empty object)
* means the shard has never published a ruleset — distinct from the feature being
* switched off, which is a 404.
*/
@Serializable
data class RulesetDto(
val shard: String? = null,
val expansion: String? = null,
/**
* The public connect address, published only when the operator set one. It is
* also the ruleset's one admin-configurable field, so it can be present for a
* signed-in viewer and absent for an anonymous one.
*/
val connect: String? = null,
/** A flat bag of on/off flags — `cityLoyalty`, `vvv`, `siege`, `chat`, … */
val systems: Map<String, Boolean> = emptyMap(),
val caps: RulesetCapsDto? = null,
val accounts: RulesetAccountsDto? = null,
val housing: RulesetHousingDto? = null,
val vetRewards: RulesetVetRewardsDto? = null,
val vendors: RulesetVendorsDto? = null,
val vvv: RulesetVvvDto? = null,
val store: RulesetStoreDto? = null,
val schedule: RulesetScheduleDto? = null,
val updatedAt: String? = null,
)
/**
* Skill and stat caps.
*
* **[skill] and [totalSkill] are in TENTHS** — 1000 is 100.0 — the way ServUO stores
* them, and the raw number is actively misleading rather than merely unhelpful (a
* "1000 skill cap" reads as a shard with ten times the usual limit). Use [skillCap]
* and [totalSkillCap]. The stat caps below them are plain values.
*/
@Serializable
data class RulesetCapsDto(
val skill: Int? = null,
val totalSkill: Int? = null,
val stat: Int? = null,
val str: Int? = null,
val dex: Int? = null,
val int: Int? = null,
val strMax: Int? = null,
val dexMax: Int? = null,
val intMax: Int? = null,
) {
val skillCap: Double? get() = skill?.let { it / 10.0 }
val totalSkillCap: Double? get() = totalSkill?.let { it / 10.0 }
}
@Serializable
data class RulesetAccountsDto(
val perIp: Int? = null,
val charSlots: Int? = null,
val autoCreate: Boolean? = null,
)
@Serializable
data class RulesetHousingDto(val accountHouseLimit: Int? = null)
@Serializable
data class RulesetVetRewardsDto(
val enabled: Boolean? = null,
val rewardIntervalDays: Int? = null,
)
@Serializable
data class RulesetVendorsDto(
val restockDelayMinutes: Int? = null,
val maxSell: Int? = null,
val economyStockAmount: Int? = null,
)
@Serializable
data class RulesetVvvDto(
val enabled: Boolean? = null,
val startSilver: Int? = null,
val enhancedRules: Boolean? = null,
)
@Serializable
data class RulesetStoreDto(
val enabled: Boolean? = null,
val currencyName: String? = null,
)
@Serializable
data class RulesetScheduleDto(
val autoSaveFrequencyMinutes: Int? = null,
val autoRestartEnabled: Boolean? = null,
val autoRestartHour: Int? = null,
val autoRestartMinute: Int? = null,
)
// ── Leaderboards (§7) ───────────────────────────────────────────────────────
/**
* One point system's board (`GET /public/shard/points`, `/points/:system`).
*
* [maxPoints] `0` means **uncapped** and is the common case, and [nameString] is
* usually null because most systems name themselves with a cliloc — the same two
* traps as [CharPointsDto], documented in full there.
*
* [players] counts players actually *holding* points, not the entry count: ten of the
* shard's systems auto-add a zero-point row for every character ever created, so the
* raw count would report the whole census as one system's participants.
*/
@Serializable
data class PointsBoardDto(
val system: String? = null,
val nameString: String? = null,
val nameNumber: Int? = null,
val maxPoints: Long? = null,
val players: Int? = null,
val showOnGump: Boolean = true,
val top: List<PointsEntryDto> = emptyList(),
val t: Long? = null,
val updatedAt: String? = null,
) {
/** The cap, or null when the system is uncapped. */
val cap: Long? get() = maxPoints?.takeIf { it > 0 }
}
/**
* A ranked character on a board. [name] is admin-configurable (the `leaderboards`
* feature's one field rule), so a shard can publish standings without naming who
* holds them — a rank with no name is a valid row, not a broken one.
*/
@Serializable
data class PointsEntryDto(
val rank: Int? = null,
val serial: String? = null,
val name: String? = null,
val points: Long? = null,
)
// ── Marketplace (§8) ────────────────────────────────────────────────────────
/**
* Where a shop stands. **Nested, not flattened**, on the wire and in the read model
* alike, so that ONE admin rule hides the facet, the coordinates, the region and the
* house together — five flat keys would be five rules that drift apart (`v3.md` §8.8).
* A null location means an admin gated it away; render that as an answer, not a blank.
*/
@Serializable
data class MarketLocationDto(
val map: String? = null,
val x: Int? = null,
val y: Int? = null,
val z: Int? = null,
val region: String? = null,
val house: String? = null,
)
/** The shop a listing belongs to, as embedded in a search result. */
@Serializable
data class MarketVendorRefDto(
val serial: String? = null,
val shopName: String? = null,
val ownerName: String? = null,
val location: MarketLocationDto? = null,
)
/**
* One item for sale. [displayName] is resolved server-side against the site's cliloc
* table, preferring a player-set [name]; a shard with no cliloc table configured sends
* neither and the item renders by id.
*
* [child] marks an item priced by an enclosing container rather than itself, exactly
* as the in-game Vendor Search reports it.
*/
@Serializable
data class MarketListingDto(
val serial: String? = null,
val itemId: Int? = null,
val hue: Int? = null,
val amount: Int? = null,
val price: Long? = null,
val name: String? = null,
val cliloc: Int? = null,
val displayName: String? = null,
val child: Boolean = false,
val vendor: MarketVendorRefDto? = null,
) {
/** What to call this item; null when the shard publishes no name for it. */
val label: String? get() = name ?: displayName
}
/**
* A page of search results (`GET /public/shard/market`).
*
* Returns **listings, not vendors**: "who sells a vanquishing kryss and for how much"
* is the question, and a vendor-shaped result would make every caller flatten the
* shops back out.
*
* [staleAt] is the oldest vendor timestamp in the index and **must be surfaced**. The
* shard sweeps vendors round-robin, so a listing can legitimately be a full cycle old;
* a page implying live prices sends someone to an item that sold twenty minutes ago.
*/
@Serializable
data class MarketPageDto(
val listings: List<MarketListingDto> = emptyList(),
val total: Int = 0,
val limit: Int? = null,
val offset: Int? = null,
val vendors: Int? = null,
val staleAt: String? = null,
)
/**
* One shop and its stock (`GET /public/shard/market/vendors/:serial`).
*
* [truncated] means the shard publishes only the first `MarketMaxListings` of a larger
* inventory — [count] is what is published, [total] what the shop holds. Saying so is
* the point of this screen: a search result list cannot express it.
*/
@Serializable
data class MarketVendorDto(
val serial: String? = null,
val shopName: String? = null,
val ownerSerial: String? = null,
val ownerName: String? = null,
val location: MarketLocationDto? = null,
val count: Int? = null,
val total: Int? = null,
val truncated: Boolean = false,
val updatedAt: String? = null,
val items: List<MarketListingDto> = emptyList(),
)
/** Index size, staleness and the filter options that actually hold vendors. */
@Serializable
data class MarketMetaDto(
val vendors: Int = 0,
val items: Int = 0,
val staleAt: String? = null,
val freshAt: String? = null,
val maps: List<String> = emptyList(),
val regions: List<String> = emptyList(),
)
// ── Spawn atlas (§6) ────────────────────────────────────────────────────────
/**
* A creature in the bestiary. Served from `/public/atlas`, **not** `/public/shard`:
* the atlas is static shard *content* parsed from the server's own data files, not
* live shard *state*, so it does not go offline with the sidecar — but unlike the
* shard routes it IS site-mode gated, like posts and the wiki.
*
* [points] is a **count** of spawners; [spawners] is the list, and only the
* single-creature route sends it. The two names are one letter apart in meaning and
* were deliberately separated (`v3.md` §6.3) — do not reuse one for the other.
*/
@Serializable
data class AtlasCreatureDto(
val slug: String? = null,
val name: String? = null,
/** How many can be alive at once, summed across every spawner. */
val total: Int? = null,
/** How many spawners mention this creature. */
val points: Int? = null,
/** Spawner count per facet. */
val facets: Map<String, Int> = emptyMap(),
/**
* Where it appears, aggregated per named place — the detail route only, and the
* answer the whole screen exists to give. **Objects, not strings:** the server
* sends `{facet, label, spawners, maxAlive}`, and typing this `List<String>`
* made the detail route fail to decode entirely.
*/
val places: List<AtlasPlaceDto> = emptyList(),
/**
* Operator-supplied sprite file name under `/uploads/atlas/`, or null — which is
* the normal state, since no artwork ships. Neither client renders it yet; the
* field is carried so a decode never depends on that staying true.
*/
val art: String? = null,
val spawners: List<AtlasSpawnerDto> = emptyList(),
val spawnersTruncated: Boolean = false,
/** Creatures sharing its spawners — the detail route only. */
val alsoHere: List<AtlasCreatureDto> = emptyList(),
)
/**
* One named place a creature spawns in, already aggregated across its spawners.
*
* [label] is the server's point-in-rect resolution of raw coordinates ("Shrines",
* "Isamu-Jima", "Yew"), falling back to the nearest landmark and finally
* "Wilderness" — turning a list of coordinates into an answer.
*/
@Serializable
data class AtlasPlaceDto(
val facet: String? = null,
val label: String? = null,
/** Spawners in this place. */
val spawners: Int? = null,
/** How many can be alive at once here, summed across those spawners. */
val maxAlive: Int? = null,
)
/**
* One spawn point.
*
* **[minDelay] / [maxDelay] are SECONDS**, normalised by the server's parser.
* XmlSpawner writes them in minutes *except* when a delay doesn't divide into whole
* minutes, flagging that per record — so the raw file has `5` meaning five minutes on
* one spawner and five seconds on the next, both plausible. The API and this client
* carry seconds throughout.
*/
@Serializable
data class AtlasSpawnerDto(
val id: Long? = null,
val facet: String? = null,
val name: String? = null,
val x: Int? = null,
val y: Int? = null,
val maxCount: Int? = null,
val minDelay: Int? = null,
val maxDelay: Int? = null,
val region: String? = null,
val landmark: String? = null,
/** The server's own "Despise, Felucca" style placement label. */
val label: String? = null,
)
/** A page of creature search results (`GET /public/atlas/creatures`). */
@Serializable
data class AtlasCreaturePageDto(
val creatures: List<AtlasCreatureDto> = emptyList(),
val total: Int = 0,
val limit: Int? = null,
val offset: Int? = null,
)
/** When the atlas was last derived from the shard's data files, and what it holds. */
@Serializable
data class AtlasMetaDto(
val importedAt: String? = null,
val generatedAt: String? = null,
val counts: Map<String, Int> = emptyMap(),
val facets: List<String> = emptyList(),
)

View File

@@ -15,11 +15,36 @@ import kotlinx.serialization.json.JsonObject
* `*.update` frames on `/public/shard/stream` decode into these same DTOs.
*/
/**
* Which shard surfaces this caller may reach (`GET /public/shard/features`), plus
* the audience rung they resolved to.
*
* Every shard-derived feature is admin-configurable — it can be switched off or
* raised to a higher rung — so the menu cannot be a static list (PLAN.md §5, M11).
* [level] is the SERVER's answer on the `anonymous → logged_in → player → staff →
* admin` ladder and is authoritative: don't re-derive a rung from the session role,
* since `player` means *a linked game account* and staff always satisfy it.
*
* The response reports only what the caller can see, so the list itself never
* discloses a feature they're gated out of.
*/
@Serializable
data class ShardFeaturesDto(
val level: String? = null,
val features: List<String> = emptyList(),
)
/**
* A game actor (player/leader/governor) as embedded in board payloads. Per the wire
* spec (`docs/link/INTEGRATION.md` §1), in-game [serial]s are opaque hex-string keys
* (e.g. `"0x1A2B"`), never numbers, and [webId] is the linked site-user id as a
* string (e.g. `"9931"`) — both are decoded as strings, not parsed.
* (e.g. `"0x1A2B"`), never numbers.
*
* [acct] and [webId] are **locked to the admin rung** by the visibility framework
* (`docs/link/v3.md` §3.4 rule 1) — a game account name and a linked site-user id are
* not in-game-visible the way a character name is, so they are stripped from every
* response below `admin` and no admin setting can loosen that. The fields stay
* declared because an admin session does receive them; nothing below one should
* expect a value.
*/
@Serializable
data class ActorDto(

View File

@@ -0,0 +1,37 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.appearance
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
/**
* Parse a JSON-valued settings row, client side — the second stage of decoding
* `nav_public` (THEMING_AND_NAV.md §3).
*
* The Kotlin counterpart to the web client's `lib/settingsJson.js`, and
* deliberately the same three lines of judgement: `settings.value` is TEXT, so
* the row arrives as a **string inside** the already-decoded settings object,
* and a malformed or wrong-shaped one must read as **absent** — the surface
* falls back to the coded default — never as an error and never as a
* half-applied object.
*/
private val settingsJson = Json { ignoreUnknownKeys = true }
/**
* @param raw the raw stored value, as it arrived in the settings payload
* @return the parsed object, or null when absent/malformed
*/
fun parseJsonSetting(raw: String?): JsonObject? {
if (raw.isNullOrEmpty()) return null
val parsed = try {
settingsJson.parseToJsonElement(raw)
} catch (_: SerializationException) {
return null
}
// Only plain objects. A stored `null`, `4`, `"x"` or array is as unusable to
// every consumer of these keys as a syntax error is.
return parsed as? JsonObject
}

View File

@@ -0,0 +1,73 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.appearance
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.api.dto.SettingsDto
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Everything the app renders itself with that the shard's admin controls
* (THEMING_AND_NAV.md, M12): the brand block, the resolved theme tokens, and the
* public navigation overrides. One value, held once in [com.runicgateway.app.ui.AppViewModel],
* so the theme and the drawer can never disagree about which shard they are showing.
*
* **[NONE] is the shipped app.** An instance with no settings rows, a backend
* that predates the feature, and a settings call that failed outright are all the
* same state here, and all three must render exactly as the app did before this
* milestone existed (§2). That is why nothing on this class is nullable except
* [brand], which was already nullable and whose absence already meant "use the
* bundled strings".
*/
data class SiteAppearance(
/** The per-shard branding block; null when settings couldn't be loaded. */
val brand: BrandDto? = null,
/**
* The resolved CSS custom properties, keyed by token (`"--accent"` → `"#7f99bd"`).
* Empty means "the shipped defaults" — the server never emits an empty map,
* but absent and empty are the same thing to the app and it must not depend
* on that.
*/
val theme: Map<String, String> = emptyMap(),
/**
* The parsed `nav_public` row, or null when the admin never edited the nav.
* Kept as the raw object here; reading `items` / `sections` / `links` out of
* it is the job of the phases that render them.
*/
val navPublic: JsonObject? = null,
) {
companion object {
/** The shipped app: no brand, no overrides. Also what a failed load means. */
val NONE = SiteAppearance()
/**
* Build the appearance from a `GET /public/settings` body. Forgiving
* field by field (§2): a bad `--accent` must not discard a good `--bg`
* beside it, and a malformed `nav_public` must not cost the theme.
*/
fun from(settings: SettingsDto?): SiteAppearance {
if (settings == null) return NONE
return SiteAppearance(
brand = settings.brand,
theme = themeTokens(settings.theme as? JsonObject),
navPublic = parseJsonSetting(settings.navPublic),
)
}
// Every themable token is a string server-side (validated on write, and
// resolveThemeTokens only ever copies a validated value). Anything else
// is dropped rather than coerced, so an unexpected value costs exactly
// its own token and the rest of the palette still applies.
private fun themeTokens(raw: JsonObject?): Map<String, String> {
if (raw.isNullOrEmpty()) return emptyMap()
return buildMap {
for ((token, value) in raw) {
val text = (value as? JsonPrimitive)?.takeIf { it.isString }?.content
if (!text.isNullOrBlank()) put(token, text)
}
}
}
}
}

View File

@@ -28,6 +28,7 @@ class ConnectionRepository @Inject constructor(
private val baseUrlHolder: BaseUrlHolder,
private val sessionManager: SessionManager,
private val trustTokenStore: TrustTokenStore,
private val shardFeaturesRepository: ShardFeaturesRepository,
private val pushManager: com.runicgateway.app.core.push.PushManager,
private val config: com.runicgateway.app.core.AppConfig,
) {
@@ -111,6 +112,10 @@ class ConnectionRepository @Inject constructor(
// The trust token is bound to the old host — drop it so we don't replay it
// against a different shard (it survives a plain logout, but not a host switch).
trustTokenStore.clear()
// Shard visibility is the OLD host's answer. Sign-out alone would not clear it:
// a switch between two signed-out hosts changes no session, so nothing else
// invalidates the cache and the new shard would inherit the old one's menu.
shardFeaturesRepository.invalidate()
prefs.clear()
baseUrlHolder.set(null)
}

View File

@@ -0,0 +1,111 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PublicApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import javax.inject.Inject
import javax.inject.Singleton
/**
* Which shard surfaces the current viewer may reach, from
* `GET /public/shard/features` (PLAN.md §5, §9 M11).
*
* Every shard-derived feature is admin-configurable — it can be switched off, or its
* audience raised above the caller's rung — so shard navigation can no longer be a
* static list gated on the session role alone. [level] is the server's own answer on
* the `anonymous → logged_in → player → staff → admin` ladder; the app does not
* re-derive it.
*
* **This is presentation only.** The gate is server-side: a disabled feature `404`s
* and an out-of-rung one `403`s whether or not the entry was rendered. That is why an
* unknown answer deliberately **fails open** — see [ShardFeatures] and [canSee].
*/
@Singleton
class ShardFeaturesRepository @Inject constructor(
private val api: PublicApi,
) {
private val _features = MutableStateFlow<ShardFeatures?>(null)
/** The current answer, or `null` while it is unknown (in flight, or the lookup failed). */
val features: StateFlow<ShardFeatures?> = _features.asStateFlow()
// Serializes concurrent refreshes: the shell refreshes on every session change,
// and two overlapping loads would race to publish.
private val mutex = Mutex()
/**
* Re-resolve the visible set. Called on every session change (sign-in, sign-out,
* a role revalidation that actually changed the user), because the answer is
* per-viewer.
*
* A failed lookup clears the cache rather than keeping a stale one: falling back
* to "show everything" is the safe direction here, since the server still gates
* every call.
*/
suspend fun refresh() = mutex.withLock {
_features.value = when (val result = safeApiCall { api.getShardFeatures() }) {
is ApiResult.Ok -> ShardFeatures(
level = result.data.level,
visible = result.data.features.toSet(),
)
// Includes the 404 an older, pre-Protocol-3.0 website returns for this
// route — that site has no visibility framework, so "unknown" is exactly
// the right answer and the menu behaves as it did before M11.
else -> null
}
}
/**
* Drop the cached answer. Called on a Settings → Server switch: the features
* belong to the host that reported them, and a switch between two signed-out
* hosts changes no session, so nothing else would invalidate them.
*/
fun invalidate() {
_features.value = null
}
}
/**
* The resolved visibility answer for one viewer: the rung the server placed them on
* and the shard features they may reach.
*/
data class ShardFeatures(
val level: String?,
val visible: Set<String>,
)
/**
* True when [feature] may be shown — **or when the answer isn't known yet**.
*
* The fail-open default is deliberate and matches the web client (`lib/useShardFeatures.js`):
* the server gates every call regardless, so the cost of guessing wrong is a link that
* briefly `403`s, while the cost of guessing the other way is a navigation drawer that
* flickers its entries in on every cold start.
*/
fun canSee(features: ShardFeatures?, feature: String): Boolean =
features == null || feature in features.visible
/** Feature names as the website's `shardVisibility.js` `FEATURES` map spells them. */
object ShardFeature {
const val STATUS = "status"
const val ACTIVITY = "activity"
const val CHAMPS = "champs"
const val GUILDS = "guilds"
const val GOVERNORS = "governors"
const val HOUSES = "houses"
const val PRESENCE = "presence"
// Added by Protocol 3.0.
const val RULESET = "ruleset"
const val ATLAS = "atlas"
const val LEADERBOARDS = "leaderboards"
const val MARKET = "market"
}

View File

@@ -8,6 +8,8 @@ import com.runicgateway.app.core.net.ShardStreamEvent
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.core.result.safeApiCall
import com.runicgateway.app.data.api.PublicApi
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.api.dto.EconomySampleDto
import com.runicgateway.app.data.api.dto.FeedEventDto
@@ -15,8 +17,13 @@ import com.runicgateway.app.data.api.dto.GovernorDto
import com.runicgateway.app.data.api.dto.GovernorTermDto
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.api.dto.MarketMetaDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import kotlinx.coroutines.flow.Flow
import kotlinx.serialization.KSerializer
@@ -62,6 +69,59 @@ class ShardRepository @Inject constructor(
suspend fun houses(): ApiResult<List<HouseDto>> = safeApiCall { api.getShardHouses() }
// ── Protocol 3.0 shard content (§9 M11) ──────────────────────────────
//
// All four sit behind `requireFeature`, so a 404/403 here is "this shard doesn't
// publish it" rather than a fault — see `toShardUiState()`.
/** The shard ruleset, or `Ok(null)` when the shard has never published one. */
suspend fun ruleset(): ApiResult<RulesetDto?> = safeApiCall { api.getShardRuleset() }
suspend fun pointsBoards(): ApiResult<List<PointsBoardDto>> = safeApiCall { api.getShardPoints() }
suspend fun pointsBoard(system: String): ApiResult<PointsBoardDto> =
safeApiCall { api.getShardPointsBoard(system) }
suspend fun market(
query: String? = null,
map: String? = null,
region: String? = null,
sort: String = SORT_PRICE_ASC,
limit: Int = MARKET_PAGE,
offset: Int = 0,
): ApiResult<MarketPageDto> = safeApiCall {
api.getShardMarket(
query = query?.takeIf { it.isNotBlank() },
map = map?.takeIf { it.isNotBlank() },
region = region?.takeIf { it.isNotBlank() },
sort = sort,
limit = limit,
offset = offset,
)
}
suspend fun marketMeta(): ApiResult<MarketMetaDto> = safeApiCall { api.getShardMarketMeta() }
suspend fun marketVendor(serial: String): ApiResult<MarketVendorDto> =
safeApiCall { api.getShardMarketVendor(serial) }
suspend fun atlasCreatures(
query: String? = null,
facet: String? = null,
limit: Int = ATLAS_PAGE,
offset: Int = 0,
): ApiResult<AtlasCreaturePageDto> = safeApiCall {
api.getAtlasCreatures(
query = query?.takeIf { it.isNotBlank() },
facet = facet?.takeIf { it.isNotBlank() },
limit = limit,
offset = offset,
)
}
suspend fun atlasCreature(slug: String): ApiResult<AtlasCreatureDto> =
safeApiCall { api.getAtlasCreature(slug) }
// ── Live stream ──────────────────────────────────────────────────────
/** The shared public SSE feed (safe kinds only), reconnecting with backoff (§7). */
fun liveEvents(): Flow<ShardStreamEvent> = stream.events()
@@ -73,9 +133,27 @@ class ShardRepository @Inject constructor(
fun governorFrame(obj: JsonObject): GovernorDto? = decode(obj, GovernorDto.serializer())
fun presenceFrame(obj: JsonObject): PresenceDto? = decode(obj, PresenceDto.serializer())
// Protocol 3.0 frames. `world.ruleset` and `points.board` ride the public stream by
// default; `vendor.listing` does NOT — the market feature ships with its SSE fan-out
// disabled (a live firehose of vendor inventories would be the site's biggest
// bandwidth consumer), so the market screen is a plain paginated read and must never
// wait on a frame.
fun rulesetFrame(obj: JsonObject): RulesetDto? = decode(obj, RulesetDto.serializer())
fun pointsBoardFrame(obj: JsonObject): PointsBoardDto? = decode(obj, PointsBoardDto.serializer())
private fun <T> decode(obj: JsonObject, serializer: KSerializer<T>): T? = try {
json.decodeFromJsonElement(serializer, obj)
} catch (_: Exception) {
null
}
companion object {
const val SORT_PRICE_ASC = "price_asc"
const val SORT_PRICE_DESC = "price_desc"
const val SORT_RECENT = "recent"
/** The server caps `limit` at 100; stay well under it on a phone. */
const val MARKET_PAGE = 50
const val ATLAS_PAGE = 50
}
}

View File

@@ -8,7 +8,7 @@ import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.data.repository.ConnectionRepository
import com.runicgateway.app.data.repository.SettingsRepository
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -20,8 +20,8 @@ import javax.inject.Inject
/**
* Top-level app gate (PLAN.md §3): decides whether the first-run connect screen
* or the main UI shows, and holds the per-shard branding the theme is seeded
* from. Activity-scoped so the whole app observes one state.
* or the main UI shows, and holds the per-shard [SiteAppearance] the theme and
* the drawer are built from. Activity-scoped so the whole app observes one state.
*/
@HiltViewModel
class AppViewModel @Inject constructor(
@@ -38,8 +38,11 @@ class AppViewModel @Inject constructor(
/** No shard site configured yet — show the connect screen. */
data object NeedsConnection : AppState
/** A site is configured; [brand] is null if branding couldn't be loaded (still usable). */
data class Ready(val brand: BrandDto?) : AppState
/**
* A site is configured. [appearance] is [SiteAppearance.NONE] when settings
* couldn't be loaded — the shipped app, still fully usable (§2).
*/
data class Ready(val appearance: SiteAppearance) : AppState
}
private val _state = MutableStateFlow<AppState>(AppState.Loading)
@@ -48,7 +51,7 @@ class AppViewModel @Inject constructor(
init {
viewModelScope.launch {
_state.value = if (connectionRepository.restore()) {
AppState.Ready(loadBrand())
AppState.Ready(loadAppearance())
} else {
AppState.NeedsConnection
}
@@ -57,7 +60,30 @@ class AppViewModel @Inject constructor(
/** Called by the connect screen once a site has been validated + saved. */
fun onConnected() {
viewModelScope.launch { _state.value = AppState.Ready(loadBrand()) }
viewModelScope.launch { _state.value = AppState.Ready(loadAppearance()) }
}
/**
* Re-read the appearance while the app is already running — on resume, beside
* the session's own re-validation (§5.5). An admin who re-skins the site from
* a laptop and picks the phone up should see it.
*
* Best-effort, and silent either way: a failed refresh **keeps the last good
* appearance** rather than dropping back to the shipped one, so a moment of
* no connectivity does not repaint a themed shard. There is no loading state
* and no error surface. Ignored unless a site is configured.
*/
fun refreshAppearance() {
if (_state.value !is AppState.Ready) return
viewModelScope.launch {
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
pushManager.setNtfyUrl(settings.push.ntfyUrl)
// changeServer() may have raced us back to the connect screen while the
// call was in flight; don't resurrect Ready on top of it.
if (_state.value is AppState.Ready) {
_state.value = AppState.Ready(SiteAppearance.from(settings))
}
}
}
/** Settings → Server switch: hard reset back to the connect screen (§3). */
@@ -69,14 +95,14 @@ class AppViewModel @Inject constructor(
}
/**
* Load public settings for branding and feed the shard's push relay URL into the
* [PushManager] (§11) — its arrival is what lets push re-register after a restart
* or sign-in. Returns the brand block (null if settings couldn't be loaded).
* Load public settings for the appearance and feed the shard's push relay URL into
* the [PushManager] (§11) — its arrival is what lets push re-register after a restart
* or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded.
*/
private suspend fun loadBrand(): BrandDto? {
private suspend fun loadAppearance(): SiteAppearance {
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
return settings?.brand
return SiteAppearance.from(settings)
}
/**

View File

@@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.ExitToApp
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -21,6 +23,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.material3.NavigationDrawerItemColors
import androidx.compose.material3.NavigationDrawerItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
@@ -32,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
@@ -48,17 +52,23 @@ import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.web.WebHandoff
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.ui.auth.AccountScreen
import com.runicgateway.app.ui.auth.LoginScreen
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
import com.runicgateway.app.ui.auth.TrustedDevicesScreen
import com.runicgateway.app.ui.auth.roleLabelRes
import com.runicgateway.app.ui.components.BrandLogo
import com.runicgateway.app.ui.contact.ContactScreen
import com.runicgateway.app.ui.home.HomeScreen
import com.runicgateway.app.ui.navigation.APP_MENU
import com.runicgateway.app.ui.navigation.NavNode
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.navigation.visibleEntries
import com.runicgateway.app.ui.navigation.buildNavTree
import com.runicgateway.app.ui.navigation.isEntryVisible
import com.runicgateway.app.ui.navigation.pruneNav
import com.runicgateway.app.ui.news.NewsScreen
import com.runicgateway.app.ui.news.PostScreen
import com.runicgateway.app.ui.admin.AdminContentScreen
@@ -72,12 +82,19 @@ import com.runicgateway.app.ui.player.CharactersScreen
import com.runicgateway.app.ui.player.MyHousesScreen
import com.runicgateway.app.ui.player.VendorsScreen
import com.runicgateway.app.ui.session.SessionViewModel
import com.runicgateway.app.ui.shard.AtlasCreatureScreen
import com.runicgateway.app.ui.shard.AtlasScreen
import com.runicgateway.app.ui.shard.ChampsScreen
import com.runicgateway.app.ui.shard.GovernorsScreen
import com.runicgateway.app.ui.shard.GuildsScreen
import com.runicgateway.app.ui.shard.HousesScreen
import com.runicgateway.app.ui.shard.LeaderboardsScreen
import com.runicgateway.app.ui.shard.MarketScreen
import com.runicgateway.app.ui.shard.MarketVendorScreen
import com.runicgateway.app.ui.shard.RulesScreen
import com.runicgateway.app.ui.shard.ShardBoard
import com.runicgateway.app.ui.shard.ShardScreen
import com.runicgateway.app.ui.theme.LocalShardStructure
import com.runicgateway.app.ui.wiki.WikiPageScreen
import com.runicgateway.app.ui.wiki.WikiScreen
import kotlinx.coroutines.launch
@@ -85,6 +102,9 @@ import kotlinx.coroutines.launch
/** Destinations that show the drawer (hamburger); others show a back arrow. */
private val TOP_LEVEL_ROUTES = setOf(
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.CONTACT, Routes.PAGE, Routes.ACCOUNT,
// Protocol 3.0 content screens are drawer destinations, so the drawer gesture works
// on them too (M11).
Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS,
Routes.NOTIFICATIONS,
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
@@ -100,18 +120,21 @@ private val TOP_LEVEL_ROUTES = setOf(
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RunicApp(
brand: BrandDto?,
appearance: SiteAppearance,
onChangeServer: () -> Unit,
modifier: Modifier = Modifier,
deepLinkStream: String? = null,
onDeepLinkConsumed: () -> Unit = {},
sessionViewModel: SessionViewModel = hiltViewModel(),
) {
val brand = appearance.brand
val navController = rememberNavController()
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val session by sessionViewModel.session.collectAsStateWithLifecycle()
// What this shard publishes, independently of who the caller is (§5, M11).
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
// Re-validate the cached role each time the app returns to the foreground (§4.3).
LifecycleResumeEffect(Unit) {
@@ -130,9 +153,37 @@ fun RunicApp(
}
val backStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = backStackEntry?.destination?.route
// A destination's route is its NavHost *pattern*, so News reports
// "news?category={category}" (§6.2). Compare on the part before the query.
val currentRoute = backStackEntry?.destination?.route?.substringBefore('?')
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
val entries = visibleEntries(APP_MENU, session)
// The admin's nav overrides, then the gates — never the other way round. An
// override is presentation only: it may relabel, reorder, group and hide, so
// `pruneNav` still decides what this caller may see and remains the boundary
// (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
isEntryVisible(it, session, shardFeatures)
}
val context = LocalContext.current
// An added link's path is site-relative; a hand-off needs it absolute against
// the configured base URL, which is exactly what the asset resolver does (§6.3).
val resolveUrl = LocalAssetResolver.current
val openNode: (NavNode) -> Unit = { node ->
scope.launch { drawerState.close() }
when (node) {
is NavNode.Item -> navController.navigateTopLevel(node.entry.route)
// A link the app resolved opens like any other drawer row, detail screen
// or not: one rule, and back-press lands on Home as it does from every
// row. One it could not resolve goes to the browser, absolute against
// the site's base URL (§6.3).
is NavNode.Link -> node.route
?.let { navController.navigateTopLevel(it) }
?: resolveUrl(node.path)?.let { WebHandoff.open(context, it) }
// Section headers aren't clickable — the group is always open (§6.3).
is NavNode.Section -> Unit
}
}
ModalNavigationDrawer(
drawerState = drawerState,
@@ -150,6 +201,14 @@ fun RunicApp(
// unreachable. See RunicGateway M10.
Column(Modifier.verticalScroll(rememberScrollState())) {
Spacer(Modifier.height(12.dp))
// The instance's logo above its name (§5.6). Decorative — the name
// is the very next line — and absent on an instance that uploaded
// none, in which case the header is exactly what it was before M12.
BrandLogo(
logo = brand?.logo,
height = 32.dp,
modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 4.dp),
)
Text(
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
style = MaterialTheme.typography.titleLarge,
@@ -158,17 +217,30 @@ fun RunicApp(
)
HorizontalDivider()
Spacer(Modifier.height(8.dp))
entries.forEach { entry ->
NavigationDrawerItem(
label = { Text(stringResource(entry.labelRes)) },
selected = currentRoute == entry.route,
onClick = {
scope.launch { drawerState.close() }
navController.navigateTopLevel(entry.route)
},
colors = drawerItemColors,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
nav.forEach { node ->
if (node is NavNode.Section) {
// A group the admin created: its label as a header, its rows
// beneath it. Always open — a drawer is already a vertical
// list, so the website's dropdown does not translate (§6.3).
Text(
text = node.label,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(
start = 28.dp,
end = 28.dp,
top = 12.dp,
bottom = 4.dp,
),
)
node.items.forEach { child ->
NavRow(child, currentRoute, drawerItemColors, indented = true) {
openNode(child)
}
}
} else {
NavRow(node, currentRoute, drawerItemColors) { openNode(node) }
}
}
HorizontalDivider(Modifier.padding(vertical = 8.dp))
@@ -192,6 +264,7 @@ fun RunicApp(
}
},
colors = drawerItemColors,
shape = LocalShardStructure.current.pill,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
NavigationDrawerItem(
@@ -202,6 +275,7 @@ fun RunicApp(
onChangeServer()
},
colors = drawerItemColors,
shape = LocalShardStructure.current.pill,
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
)
}
@@ -219,13 +293,24 @@ fun RunicApp(
actionIconContentColor = MaterialTheme.colorScheme.onSurface,
),
title = {
Text(
text = (brand?.name?.takeIf { it.isNotBlank() }
?: stringResource(R.string.app_name)).uppercase(),
style = MaterialTheme.typography.titleSmall.copy(letterSpacing = 1.2.sp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val name = brand?.name?.takeIf { it.isNotBlank() }
?: stringResource(R.string.app_name)
// The logo stands in for the title here, so unlike the drawer's
// it is named for a screen reader — and it falls back to the
// text when the instance has no logo or the load fails (§5.6).
BrandLogo(
logo = brand?.logo,
height = 24.dp,
contentDescription = name,
) {
Text(
text = name.uppercase(),
style = MaterialTheme.typography.titleSmall
.copy(letterSpacing = 1.2.sp),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
},
navigationIcon = {
if (isTopLevel) {
@@ -256,6 +341,62 @@ fun RunicApp(
}
}
/**
* One drawer row: a coded entry, or an admin's added link (§6.3).
*
* A link that the app can open natively is deliberately indistinguishable from a
* coded row — that is the point of resolving it. One that hands off to the browser
* carries a trailing icon, so leaving the app is never a surprise.
*/
@Composable
private fun NavRow(
node: NavNode,
currentRoute: String?,
colors: NavigationDrawerItemColors,
indented: Boolean = false,
onClick: () -> Unit,
) {
val route = when (node) {
is NavNode.Item -> node.entry.route
is NavNode.Link -> node.route
is NavNode.Section -> null
}
val label = when (node) {
// An admin's label wins over the bundled one, and is the same string in
// every locale — see MenuEntry.label.
is NavNode.Item -> node.entry.label ?: stringResource(node.entry.labelRes)
is NavNode.Link -> node.label
is NavNode.Section -> return
}
val handsOff = node is NavNode.Link && node.route == null
NavigationDrawerItem(
label = { Text(label) },
selected = route != null && currentRoute == route.substringBefore('?'),
onClick = onClick,
badge = if (!handsOff) {
null
} else {
{
Icon(
Icons.AutoMirrored.Filled.ExitToApp,
contentDescription = stringResource(R.string.nav_opens_in_browser),
modifier = Modifier.size(18.dp),
)
}
},
colors = colors,
// Like Card's elevation, NavigationDrawerItem takes its shape as a default
// argument (CircleShape) rather than from the theme, so --radius-pill has to
// be handed to it at every call site or the selected row stays fully round
// while every other radius follows the shard (phase 8's AC-5 walk).
shape = LocalShardStructure.current.pill,
modifier = Modifier
.padding(NavigationDrawerItemDefaults.ItemPadding)
.padding(start = if (indented) 16.dp else 0.dp),
)
}
@Composable
private fun RunicNavHost(
navController: NavHostController,
@@ -273,7 +414,19 @@ private fun RunicNavHost(
composable(Routes.HOME) {
HomeScreen(brand = brand)
}
composable(Routes.NEWS) {
// The category is optional: navigating to plain Routes.NEWS matches this
// pattern with no argument and opens the default tab, which is every route
// into the screen except an admin's nav override or added link (§6.2).
composable(
route = Routes.NEWS_ROUTE,
arguments = listOf(
navArgument(Routes.Args.CATEGORY) {
type = NavType.StringType
nullable = true
defaultValue = null
},
),
) {
NewsScreen(onOpenPost = { category, idOrSlug ->
navController.navigate(Routes.post(category, idOrSlug))
})
@@ -303,6 +456,30 @@ private fun RunicNavHost(
composable(Routes.SHARD_GUILDS) { GuildsScreen() }
composable(Routes.SHARD_GOVERNORS) { GovernorsScreen() }
composable(Routes.SHARD_HOUSES) { HousesScreen() }
// Protocol 3.0 shard content (M11). Each screen self-reports "not published
// here" from its own 404/403, so a deep link to a gated feature still lands on
// an honest answer even though the menu hides the entry.
composable(Routes.SHARD_RULES) { RulesScreen() }
composable(Routes.SHARD_LEADERBOARDS) { LeaderboardsScreen(brand = brand) }
composable(Routes.SHARD_MARKET) {
MarketScreen(onOpenVendor = { serial -> navController.navigate(Routes.marketVendor(serial)) })
}
composable(
route = Routes.SHARD_MARKET_VENDOR,
arguments = listOf(navArgument(Routes.Args.SERIAL) { type = NavType.StringType }),
) { entry ->
MarketVendorScreen(serial = entry.arguments?.getString(Routes.Args.SERIAL).orEmpty())
}
composable(Routes.ATLAS) {
AtlasScreen(onOpenCreature = { slug -> navController.navigate(Routes.atlasCreature(slug)) })
}
composable(
route = Routes.ATLAS_CREATURE,
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
) { entry ->
AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty())
}
composable(Routes.WIKI) {
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
}

View File

@@ -34,6 +34,13 @@ enum class ErrorKind {
/** Shard/sidecar down (503) — shard reads only; render as offline (§6.3). */
SHARD_OFFLINE,
/**
* This shard doesn't publish the surface, or doesn't publish it to this viewer
* (M11). Distinct from [NOT_FOUND] and [SHARD_OFFLINE]: the site is up, the shard
* may well be up, and retrying changes nothing — an admin decides this.
*/
FEATURE_UNAVAILABLE,
/** Any other non-2xx server response. */
SERVER,
}
@@ -52,3 +59,27 @@ fun <T> ApiResult<T>.toUiState(): UiState<T> = when (this) {
httpStatus = status,
)
}
/**
* [toUiState] for a **shard-derived** read, where `404` carries a second meaning.
*
* The website's `requireFeature` gate answers `404` when a feature is switched off —
* deliberately, so the response doesn't disclose that the surface exists — and `403`
* when it's on but the caller is below its audience rung (`docs/link/v3.md` §3.6).
* On these routes a `404` therefore almost never means "no such thing"; it means this
* shard doesn't publish it. Rendering "couldn't be found" with a retry button would
* invite the user to retry something an admin controls.
*
* Kept as a separate mapper rather than folded into [toUiState] because both statuses
* mean something else off the shard surface: `404` is a genuinely missing item (a
* deleted post, an unknown wiki slug) and `403` is an ownership or role refusal on a
* player or admin route, which is not an admin's visibility setting.
*/
fun <T> ApiResult<T>.toShardUiState(): UiState<T> = when (this) {
is ApiResult.HttpError -> if (status == 403 || status == 404) {
UiState.Error(ErrorKind.FEATURE_UNAVAILABLE, httpStatus = status)
} else {
toUiState()
}
else -> toUiState()
}

View File

@@ -15,7 +15,6 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.FilterChip
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
@@ -46,6 +45,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
@@ -139,7 +139,7 @@ private fun PostsTab(
}
}
items(state.data, key = { it.id }) { post ->
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
Text(post.title, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(4.dp))
@@ -190,7 +190,7 @@ private fun WikiTab(
}
}
items(state.data, key = { it.id }) { cat ->
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
Text(cat.title, style = MaterialTheme.typography.bodyLarge)
Text(

View File

@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
@@ -38,6 +37,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* The support (help-page) queue (PLAN.md §1, M10): open tickets with reply/close,
@@ -100,7 +100,7 @@ private fun SupportPageCard(
onReply: () -> Unit,
onClose: () -> Unit,
) {
Card(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
ShardCard(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Column(Modifier.padding(12.dp)) {
val who = page.sender?.name ?: page.sender?.account ?: page.pageId
Text(

View File

@@ -17,7 +17,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -51,6 +50,7 @@ import com.runicgateway.app.ui.auth.AccountViewModel.Section
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
@@ -107,7 +107,7 @@ fun AccountScreen(
@Composable
private fun IdentityCard(username: String, roleLabel: String) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(20.dp)) {
Text(text = username, style = MaterialTheme.typography.titleLarge)
StatusPill(
@@ -154,7 +154,7 @@ private fun SecuritySection(onOpenTrustedDevices: () -> Unit, onOpenRecoveryCode
@Composable
private fun SectionCard(@StringRes titleRes: Int, content: @Composable () -> Unit) {
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
content()

View File

@@ -14,7 +14,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
@@ -38,6 +37,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ShardCard
/**
* Account → Recovery Codes (TRUSTED_DEVICES_MFA.md): shows the remaining count and a
@@ -117,7 +117,7 @@ fun RecoveryCodesShowOnceCard(codes: List<String>, onDismiss: () -> Unit) {
val clipboard = LocalClipboardManager.current
val joined = remember(codes) { codes.joinToString("\n") }
Card(Modifier.fillMaxWidth().padding(top = 16.dp)) {
ShardCard(Modifier.fillMaxWidth().padding(top = 16.dp)) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.recovery_codes_new_title), style = MaterialTheme.typography.titleMedium)
Text(

View File

@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
@@ -30,6 +29,7 @@ import com.runicgateway.app.data.api.dto.TrustedDeviceDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* Account → Trusted Devices (TRUSTED_DEVICES_MFA.md): the devices allowed to skip
@@ -108,7 +108,7 @@ fun TrustedDevicesScreen(
@Composable
private fun TrustedDeviceRow(device: TrustedDeviceDto, busy: Boolean, onRevoke: () -> Unit) {
Card(Modifier.fillMaxWidth().padding(top = 12.dp)) {
ShardCard(Modifier.fillMaxWidth().padding(top = 12.dp)) {
Row(
Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,

View File

@@ -0,0 +1,157 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.runicgateway.app.ui.LocalAssetResolver
/**
* The two brand assets an instance can upload — the logo and the hero
* (THEMING_AND_NAV.md §5.6, M12 phase 4). Both have ridden in `BrandDto` since
* M1 and neither has ever been drawn; the app has always spelled the instance
* out in text wherever the website shows a mark.
*
* **The rule that governs this whole file: an empty slot renders nothing.** Not
* a placeholder, not a reserved gap, not the app's own emblem — an instance
* that has uploaded no logo must lay out exactly as it did before this phase
* existed, which is §2 applied to assets. The website's `BrandLogo.jsx` opens
* with the same `if (!brand.logo) return null`.
*
* **A failed load is an empty slot.** No broken-image icon and no retry: an
* asset that 404s, or that can't be reached because the shard is down, must
* degrade to the same layout as an instance that never uploaded one. That is
* why nothing here reserves its space up front — every size modifier hangs off
* the image itself, so when the image isn't composed neither is its padding.
* A caller that wants space *below* a hero passes it as `Modifier.padding`
* rather than a sibling `Spacer`, and gets both cases right for free.
*/
/**
* Widest a logo may draw, as a multiple of its height. Mirrors the website's
* `maxWidth: height * 6` — an operator who uploads a long wordmark gets it
* scaled down rather than pushing the drawer header or the top bar's title out
* of shape.
*/
private const val LOGO_MAX_ASPECT = 6f
/** The Home hero's band height (§5.6, phase 4). See [BrandHero] for why it's fixed. */
private val HERO_HEIGHT = 180.dp
/**
* The instance's uploaded logo at [height], or [fallback] when there is none.
*
* [fallback] defaults to drawing nothing, which is what the drawer header wants:
* the instance name sits directly below it, so an instance with no logo simply
* has the name where it has always been. The top bar passes the name itself,
* because there the logo *replaces* the title — leaving that blank on a failed
* load would strand the app in an unnamed shell until the next resume refresh,
* and "a failed load is an empty slot" means the slot falls back to whatever
* empty would have shown, which for the top bar is the text.
*
* There is deliberately no fallback while the load is still in flight. Drawing
* the text first would flash text → logo on every navigation for the sake of
* one frame, since Coil serves the second and later reads from its memory cache.
*
* Pass [contentDescription] only where the logo stands alone. Beside or above
* the name in text it is decorative, and describing it would have a screen
* reader say the instance's name twice — the same call the website's `alt=''`
* makes.
*/
@Composable
fun BrandLogo(
logo: String?,
height: Dp,
modifier: Modifier = Modifier,
contentDescription: String? = null,
fallback: @Composable () -> Unit = {},
) {
val url = brandAssetUrl(logo, LocalAssetResolver.current)
// Keyed on the url so a refreshed appearance that swaps the logo (§5.5) gets
// a fresh attempt rather than inheriting the old one's failure.
var failed by remember(url) { mutableStateOf(false) }
if (url == null || failed) {
fallback()
return
}
AsyncImage(
model = url,
contentDescription = contentDescription,
contentScale = ContentScale.Fit,
onError = { failed = true },
modifier = modifier
.height(height)
.widthIn(max = height * LOGO_MAX_ASPECT),
)
}
/**
* The instance's hero image as a full-width band above Home's title block, or
* nothing when there is none.
*
* **Fixed height and cropped**, rather than the intrinsic aspect ratio the app's
* other images (`PostScreen`, `BlockRenderer`) draw at. The website's hero is a
* CSS background driven by `hero_layout`, which the app does not port, so the
* app needs its own rule — and the website's *default* hero is a square emblem,
* so an uploaded square is a case to expect rather than an edge one. At the
* intrinsic aspect that square would be a ~360dp block that pushes the status
* card off the first screenful; cropped to a band, a wide banner and a square
* both give the same frame above the title.
*
* Clipped to `shapes.medium`, so the hero follows the shard's `--radius-card`
* like every other surface the admin can round off (§5.2).
*
* Decorative: Home spells the instance's name and tagline out in text directly
* below, so the hero carries no content description.
*/
@Composable
fun BrandHero(hero: String?, modifier: Modifier = Modifier) {
val url = brandAssetUrl(hero, LocalAssetResolver.current)
var failed by remember(url) { mutableStateOf(false) }
if (url == null || failed) return
AsyncImage(
model = url,
contentDescription = null,
contentScale = ContentScale.Crop,
onError = { failed = true },
modifier = modifier
.fillMaxWidth()
.height(HERO_HEIGHT)
.clip(MaterialTheme.shapes.medium),
)
}
/**
* Resolve a brand asset slot to a loadable URL, or null when the slot is empty.
*
* The blank check has to happen on **both** sides of [resolve]: `BrandDto`
* defaults every asset field to `""` rather than null (the server publishes the
* empty string for "not set"), and a resolver given a path it cannot make
* absolute may hand one straight back. Null out of here is the signal for "draw
* nothing", so a blank slipping through would put a zero-size image request in
* the layout instead of no image at all.
*
* Pulled out of the composables purely so it can be tested: the app has no
* Robolectric, so a composable body cannot run in a JVM unit test, but this rule
* is the whole of §5.6's "renders nothing when unset" and it is worth pinning.
*/
internal fun brandAssetUrl(path: String?, resolve: (String?) -> String?): String? =
path?.takeIf { it.isNotBlank() }
?.let(resolve)
?.takeIf { it.isNotBlank() }

View File

@@ -36,6 +36,10 @@ fun LoadingView(modifier: Modifier = Modifier) {
/**
* Whole-screen error state with a friendly, kind-specific message and a Retry
* button (§7). Copy is resolved from string resources so it stays localizable.
*
* [ErrorKind.FEATURE_UNAVAILABLE] renders **without** the button: an admin decides
* whether the shard publishes that surface, so retrying cannot change the answer and
* offering it would read as a transient failure the user could wait out (M11).
*/
@Composable
fun ErrorView(
@@ -53,15 +57,20 @@ fun ErrorView(
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
)
Button(
onClick = onRetry,
modifier = Modifier.padding(top = 16.dp).width(160.dp),
) {
Text(stringResource(R.string.action_retry))
if (isRetryable(kind)) {
Button(
onClick = onRetry,
modifier = Modifier.padding(top = 16.dp).width(160.dp),
) {
Text(stringResource(R.string.action_retry))
}
}
}
}
/** Whether retrying this failure could plausibly succeed. Pure, so it is unit-tested. */
fun isRetryable(kind: ErrorKind): Boolean = kind != ErrorKind.FEATURE_UNAVAILABLE
/** Centered informational message for an empty list (§7). */
@Composable
fun EmptyView(message: String, modifier: Modifier = Modifier) {
@@ -83,5 +92,6 @@ private fun errorMessageRes(kind: ErrorKind): Int = when (kind) {
ErrorKind.NOT_FOUND -> R.string.error_not_found
ErrorKind.RATE_LIMITED -> R.string.error_rate_limited
ErrorKind.SHARD_OFFLINE -> R.string.error_shard_offline
ErrorKind.FEATURE_UNAVAILABLE -> R.string.error_feature_unavailable
ErrorKind.SERVER -> R.string.error_server
}

View File

@@ -14,24 +14,22 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import com.runicgateway.app.ui.theme.ShardCardBottom
import com.runicgateway.app.ui.theme.ShardCardTop
import com.runicgateway.app.ui.theme.LocalShardPalette
import com.runicgateway.app.ui.theme.LocalShardStructure
import com.runicgateway.app.ui.theme.ShardDanger
import com.runicgateway.app.ui.theme.ShardDangerBg
import com.runicgateway.app.ui.theme.ShardElevated
import com.runicgateway.app.ui.theme.ShardFaint
import com.runicgateway.app.ui.theme.ShardOutline
import com.runicgateway.app.ui.theme.ShardPillBg
import com.runicgateway.app.ui.theme.ShardPillFg
import com.runicgateway.app.ui.theme.ShardSuccess
import com.runicgateway.app.ui.theme.ShardSuccessBg
import com.runicgateway.app.ui.theme.ShardSuccessDot
@@ -43,6 +41,14 @@ import com.runicgateway.app.ui.theme.ShardWarningBg
* (docs/android/PLAN.md §M5): the recurring pill, section-label, feature-card,
* and stat-bar motifs the mockup repeats across screens. Pure presentation —
* no state, no data dependencies — so any screen can adopt them.
*
* This is the app's **only** file that reaches past `MaterialTheme` for a
* themable value, so it is the one place M12 had to migrate: the surface, line
* and accent tokens come from [LocalShardPalette] and the pill shape and card
* depth from [LocalShardStructure], both following the shard's theme
* (THEMING_AND_NAV.md §5.1, §5.2, §5.4). The success/warning/danger constants
* stay imported directly — those are semantic and never themed, mirroring the
* server's `FIXED_TOKENS`.
*/
/** Semantic tone for a [StatusPill] / [OnlineDot]. */
@@ -50,21 +56,26 @@ enum class PillTone { Success, Warning, Danger, Neutral, Info }
private data class PillColors(val fg: Color, val bg: Color)
@Composable
private fun toneColors(tone: PillTone): PillColors = when (tone) {
PillTone.Success -> PillColors(ShardSuccess, ShardSuccessBg)
PillTone.Warning -> PillColors(ShardWarning, ShardWarningBg)
PillTone.Danger -> PillColors(ShardDanger, ShardDangerBg)
PillTone.Neutral, PillTone.Info -> PillColors(ShardPillFg, ShardPillBg)
PillTone.Neutral, PillTone.Info ->
LocalShardPalette.current.let { PillColors(it.pillFg, it.pillBg) }
}
/**
* A small uppercase status chip — "Live", "Up", "Enabled", "IDOC", a role — with a
* rounded filled background tinted by [tone]. Mirrors the mockup's pill badges.
*
* The one place `--radius-pill` lands: the app's other two [CircleShape] uses are
* 8dp status dots, and a dot stays a dot however square the shard makes its site.
*/
@Composable
fun StatusPill(text: String, tone: PillTone, modifier: Modifier = Modifier) {
val c = toneColors(tone)
Surface(color = c.bg, shape = CircleShape, modifier = modifier) {
Surface(color = c.bg, shape = LocalShardStructure.current.pill, modifier = modifier) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall,
@@ -81,7 +92,7 @@ fun OnlineDot(tone: PillTone, modifier: Modifier = Modifier) {
PillTone.Success -> ShardSuccessDot
PillTone.Warning -> ShardWarning
PillTone.Danger -> ShardDanger
PillTone.Neutral, PillTone.Info -> ShardFaint
PillTone.Neutral, PillTone.Info -> LocalShardPalette.current.faint
}
Box(modifier.size(8.dp).clip(CircleShape).background(color))
}
@@ -95,7 +106,7 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
Text(
text = text.uppercase(),
style = MaterialTheme.typography.labelSmall,
color = ShardFaint,
color = LocalShardPalette.current.faint,
modifier = modifier,
)
}
@@ -104,6 +115,11 @@ fun SectionLabel(text: String, modifier: Modifier = Modifier) {
* The elevated "feature" card: a vertical blue gradient with a hairline outline and
* soft shadow, used for the home status card, the shard-online banner, and the
* vendor card. [content] is laid out in a padded [Column].
*
* The radius is `MaterialTheme.shapes.medium` rather than the literal 12dp it was
* built with — the same value, now following `--radius-card`'s ratio (§5.2). The
* shadow this doc always claimed is finally drawn, at the depth `--shadow-card`
* resolves to (§5.4).
*/
@Composable
fun FeatureCard(
@@ -111,17 +127,40 @@ fun FeatureCard(
contentPadding: Int = 18,
content: @Composable ColumnScope.() -> Unit,
) {
val palette = LocalShardPalette.current
val shape = MaterialTheme.shapes.medium
Box(
modifier = modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(Brush.verticalGradient(listOf(ShardCardTop, ShardCardBottom)))
.border(1.dp, ShardOutline, RoundedCornerShape(12.dp)),
.shadow(LocalShardStructure.current.cardElevation, shape)
.clip(shape)
.background(Brush.verticalGradient(listOf(palette.cardTop, palette.cardBottom)))
.border(1.dp, palette.outline, shape),
) {
Column(Modifier.padding(contentPadding.dp), content = content)
}
}
/**
* A Material [Card] at the shard's resolved depth — the app's standard card, and
* the reason every screen's `Card(` became a `ShardCard(`.
*
* `Card` takes its elevation as a **default argument**, not from the theme, so
* unlike the color scheme and the shape scale there is no way to make
* `--shadow-card` reach ~24 call sites without a wrapper. Passing
* [CardDefaults.cardElevation] at each site instead would have put the same line
* in eighteen files and let one drift. A `Card(` outside this file is therefore a
* card the shard cannot theme, which makes the invariant greppable.
*/
@Composable
fun ShardCard(modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit) {
Card(
modifier = modifier,
elevation = CardDefaults.cardElevation(defaultElevation = LocalShardStructure.current.cardElevation),
content = content,
)
}
/**
* A slim rounded meter (vitals / skills). [fraction] is clamped to 0..1; the fill is
* the slate accent over a bordered dark track.
@@ -129,13 +168,14 @@ fun FeatureCard(
@Composable
fun StatBar(fraction: Float, modifier: Modifier = Modifier) {
val pct = fraction.coerceIn(0f, 1f)
val palette = LocalShardPalette.current
Box(
modifier = modifier
.fillMaxWidth()
.height(6.dp)
.clip(RoundedCornerShape(3.dp))
.background(ShardElevated)
.border(1.dp, ShardOutline, RoundedCornerShape(3.dp)),
.background(palette.elevated)
.border(1.dp, palette.outline, RoundedCornerShape(3.dp)),
) {
Box(
Modifier

View File

@@ -26,6 +26,7 @@ import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.BrandHero
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.FeatureCard
import com.runicgateway.app.ui.components.LoadingView
@@ -59,6 +60,12 @@ private fun HomeContent(brand: BrandDto?, status: StatusDto, modifier: Modifier
.verticalScroll(rememberScrollState())
.padding(20.dp),
) {
// The instance's hero above the title block (§5.6) — Home is the one screen
// with a hero-shaped space. Its bottom gap rides on the image's own modifier
// rather than a Spacer, so an instance with no hero (or one whose hero fails
// to load) opens on the title exactly where it has always been.
BrandHero(hero = brand?.hero, modifier = Modifier.padding(bottom = 16.dp))
Text(
text = brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name),
style = MaterialTheme.typography.headlineMedium,

View File

@@ -6,6 +6,9 @@ package com.runicgateway.app.ui.navigation
import androidx.annotation.StringRes
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.canSee
/**
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
@@ -40,6 +43,24 @@ data class MenuEntry(
val route: String,
@param:StringRes val labelRes: Int,
val access: MenuAccess = MenuAccess.PUBLIC,
/**
* For a shard-derived surface, the visibility feature it belongs to (M11).
*
* Session role is not the only gate on these: an admin can switch a feature off
* or raise its audience above the caller's rung, so the entry is filtered by
* `GET /public/shard/features` as well as by [access]. `null` means the entry
* isn't shard-derived and only [access] applies.
*/
val feature: String? = null,
/**
* An admin's own label for this row, from the shard's `nav_public` override
* (THEMING_AND_NAV.md §6). Null — always, as coded — means [labelRes] stands.
*
* A label set this way is **not localized**: it is one string for every locale,
* which is what an admin typing a label means, and it matches the website. It
* only ever arrives via [applyNavOverrides]; nothing in [APP_MENU] sets it.
*/
val label: String? = null,
)
/**
@@ -51,7 +72,13 @@ val APP_MENU: List<MenuEntry> = listOf(
MenuEntry(Routes.HOME, R.string.menu_home),
MenuEntry(Routes.NEWS, R.string.menu_news),
MenuEntry(Routes.WIKI, R.string.menu_wiki),
MenuEntry(Routes.SHARD, R.string.menu_shard),
MenuEntry(Routes.SHARD, R.string.menu_shard, feature = ShardFeature.STATUS),
// Protocol 3.0 shard content (M11). Each hides when the shard doesn't publish it,
// which for a brand-new install is every one of them until the plugin has swept.
MenuEntry(Routes.SHARD_RULES, R.string.menu_rules, feature = ShardFeature.RULESET),
MenuEntry(Routes.ATLAS, R.string.menu_atlas, feature = ShardFeature.ATLAS),
MenuEntry(Routes.SHARD_LEADERBOARDS, R.string.menu_leaderboards, feature = ShardFeature.LEADERBOARDS),
MenuEntry(Routes.SHARD_MARKET, R.string.menu_market, feature = ShardFeature.MARKET),
MenuEntry(Routes.page("about"), R.string.menu_about),
MenuEntry(Routes.CONTACT, R.string.menu_contact),
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
@@ -67,16 +94,40 @@ val APP_MENU: List<MenuEntry> = listOf(
)
/**
* The entries the given [session] may see. Pure + side-effect-free so the access
* gating is unit-tested without Compose.
* The entries the given [session] may see, given the shard [features] it may reach.
* Pure + side-effect-free so the gating is unit-tested without Compose.
*
* Two independent filters, and both must pass:
*
* - [MenuEntry.access] against the session — who the caller is.
* - [MenuEntry.feature] against the shard's live visibility config — what this shard
* publishes at all (M11). `null` [features] means the answer isn't known yet and
* every shard entry shows; see [canSee] for why that direction is deliberate.
*/
fun visibleEntries(entries: List<MenuEntry>, session: Session): List<MenuEntry> =
entries.filter { entry ->
when (entry.access) {
MenuAccess.PUBLIC -> true
MenuAccess.SIGNED_IN -> session is Session.SignedIn
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
}
fun visibleEntries(
entries: List<MenuEntry>,
session: Session,
features: ShardFeatures? = null,
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features) }
/**
* [visibleEntries] for a single entry — the same two filters, and the same
* boundary. Split out because the drawer is a tree once an admin groups rows into
* sections (§6.3): [pruneNav] applies this predicate inside a section as well, and
* both callers must ask exactly one question or a sectioned row could be gated by
* a rule its top-level twin is not.
*/
fun isEntryVisible(
entry: MenuEntry,
session: Session,
features: ShardFeatures? = null,
): Boolean {
val allowedByRole = when (entry.access) {
MenuAccess.PUBLIC -> true
MenuAccess.SIGNED_IN -> session is Session.SignedIn
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
}
return allowedByRole && (entry.feature == null || canSee(features, entry.feature))
}

View File

@@ -0,0 +1,171 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.doubleOrNull
/**
* Apply the admin's stored public-nav overrides to the app's coded menu
* (THEMING_AND_NAV.md §6). The Kotlin counterpart of the website's
* `client/src/lib/navOverrides.js`, narrowed to what a drawer can express.
*
* **This is presentation, never authorization.** An override carries `label`,
* `order` and `hidden` and nothing else: it cannot introduce a route, cannot
* touch [MenuEntry.access] or [MenuEntry.feature], and cannot un-hide anything —
* `hidden: false` is simply the absence of hiding. [visibleEntries] therefore runs
* **after** this merge, unchanged, and remains the actual boundary (§6.1, AC-3).
*
* Fail-safe throughout, matching the web: anything unrecognized — an unknown path,
* a non-string label, a path the app doesn't surface in its menu — is ignored
* rather than rejected, so a stale or hand-edited settings row degrades to the
* coded menu instead of rendering a broken drawer.
*/
/** A usable override for one menu row. Absent fields mean "as coded". */
internal data class NavOverride(
val label: String? = null,
val order: Double? = null,
val hidden: Boolean = false,
/**
* The id of the section this row was dropped into, or null for a top-level
* row. Read here but honored only by the tree build (`NavTree.kt`) — the flat
* [applyNavOverrides] has nowhere to put it. Not validated against the stored
* sections here; that is the tree's job, since only it knows them.
*/
val section: String? = null,
) {
/**
* Nothing a **flat** list can express. [section] is deliberately not part of
* this: to [applyNavOverrides] a section-only override says nothing, so an
* instance that only ever grouped rows still gets its coded list back by
* identity. The tree build adds its own check.
*/
val isEmpty: Boolean get() = label == null && order == null && !hidden
}
/**
* The `items` map out of a stored `nav_public` value.
*
* Two shapes exist, because website phase 10 added sections and links without
* migrating what phases 6-8 had already stored: `{items, sections, links}` and a
* bare map of path → override. A bare map is unambiguous — every key is a path,
* so a key can never be the string `items`.
*
* `sections` and `links` come out of the same wrapper, and only ever out of the
* wrapped shape — see [sectionsOf] and [linksOf].
*/
internal fun itemsOf(navPublic: JsonObject?): Map<String, JsonObject> {
if (navPublic == null) return emptyMap()
val items = wrapperOf(navPublic)?.get("items") as? JsonObject ?: navPublic
return items.entries
.mapNotNull { (key, value) -> (value as? JsonObject)?.let { key to it } }
.toMap()
}
/**
* The stored value as the wrapped `{items, sections, links}` shape, or null when
* it is the bare items map phases 6-8 wrote. The discriminator is the web's: an
* `items` **object**, which a bare map can never carry because every key in one is
* a path.
*/
private fun wrapperOf(navPublic: JsonObject?): JsonObject? =
navPublic?.takeIf { it["items"] is JsonObject }
internal fun sectionsOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"sections")
internal fun linksOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"links")
private fun jsonObjectsAt(navPublic: JsonObject?, key: String): List<JsonObject> =
(wrapperOf(navPublic)?.get(key) as? JsonArray)
?.mapNotNull { it as? JsonObject }
.orEmpty()
// Field by field, like every other read in M12: a bad `label` must not discard a
// good `order` beside it.
//
// `group` is ignored — it names a section of the *admin sidebar*, a nav the app
// never renders, and a value it cannot honor is better dropped than half-applied.
internal fun cleanOverride(raw: JsonObject): NavOverride {
val label = (raw["label"] as? JsonPrimitive)
?.takeIf { it.isString }
?.content
?.trim()
?.takeIf { it.isNotEmpty() }
val order = (raw["order"] as? JsonPrimitive)
?.takeIf { !it.isString }
?.doubleOrNull
?.takeIf { it.isFinite() }
val hidden = (raw["hidden"] as? JsonPrimitive)
?.takeIf { !it.isString }
?.booleanOrNull == true
val section = (raw["section"] as? JsonPrimitive)
?.takeIf { it.isString }
?.content
?.takeIf { it.isNotEmpty() }
return NavOverride(label = label, order = order, hidden = hidden, section = section)
}
/**
* [base] with the admin's overrides applied: rows relabeled, reordered and
* dropped as the stored row asks.
*
* @param base the coded menu — the only source of `route`, `access` and `feature`
* @param navPublic the parsed `nav_public` row, or null when the admin never
* edited the nav. Null, malformed, and "nothing usable in it" all return [base]
* itself, which is what makes an untouched instance's drawer provably today's
* (§2, AC-1).
*/
fun applyNavOverrides(base: List<MenuEntry>, navPublic: JsonObject?): List<MenuEntry> {
val items = itemsOf(navPublic)
if (items.isEmpty()) return base
val coded = base.map { it.route }.toSet()
// Keyed by app route, and only for a route the coded menu actually declares.
// This is where an override for a path the app doesn't surface in its drawer —
// a news category tab, a Shard hub board — is dropped (§6.2). The web does the
// same with an unknown `to`.
val overrides = buildMap {
for ((path, raw) in items) {
val route = appRouteForWebPath(path) ?: continue
if (route !in coded) continue
val override = cleanOverride(raw)
if (!override.isEmpty) put(route, override)
}
}
if (overrides.isEmpty()) return base
// Rows the website's nav knows about are the ones an override can move; the
// app's own surfaces (Contact, Account, the player groups, the staff rows)
// have no counterpart to be reordered against and keep their coded order,
// appended after the public block — which is exactly where they sit today, so
// this partition is the current layout rather than a new one (§6.2).
val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }
val sorted = mapped
// An untouched row's sort key is its index in the WEBSITE's nav, not the
// app's: a stored `order` is a position in that list, so both keys have to
// sit on one number line to be comparable at all.
//
// Two tie-breaks, the web's: an explicit order beats a coincidental index
// (the admin said "first", so first), and two explicit orders keep code
// order, because the sort is stable.
.sortedWith(
compareBy<MenuEntry> { entry ->
overrides[entry.route]?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble()
}.thenByDescending { overrides[it.route]?.order != null },
)
return (sorted + appOnly).mapNotNull { entry ->
val override = overrides[entry.route] ?: return@mapNotNull entry
when {
override.hidden -> null
override.label != null -> entry.copy(label = override.label)
else -> entry
}
}
}

View File

@@ -0,0 +1,220 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
/**
* The website path → app route table (THEMING_AND_NAV.md §6.2).
*
* The public nav an admin edits is keyed by **website** paths, so honoring it in
* the app needs a translation. This is the one new piece of cross-repo coupling
* the milestone introduces, which is why it lives in a single file with the
* website's own array quoted right beside it — the coupling is visible and
* reviewable in one place rather than spread across the drawer's call sites.
*
* Verbatim from `website/client/src/components/SiteHeader.jsx`, which is the
* exported owner of the list (`export const NAV`, and Admin → Navigation edits
* exactly it):
*
* ```js
* export const NAV = [
* { label: 'Home', to: '/', end: true },
* { label: 'News', to: '/site/news' },
* { label: 'Screenshots', to: '/site/screenshots' },
* { label: 'Five on Friday', to: '/site/five-on-friday' },
* { label: 'Newsletter', to: '/site/newsletter' },
* { label: 'Wiki', to: '/wiki' },
* { label: 'Shard', to: '/site/shard', feature: 'status' },
* { label: 'Champions', to: '/site/champs', feature: 'champs' },
* { label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
* { label: 'Governors', to: '/site/governors', feature: 'governors' },
* { label: 'Houses', to: '/site/houses', feature: 'houses' },
* { label: 'Rules', to: '/site/rules', feature: 'ruleset' },
* { label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
* { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
* { label: 'Market', to: '/site/market', feature: 'market' },
* { label: 'About', to: '/site/about' },
* ]
* ```
*
* The `feature` values are **not** mirrored here on purpose. [APP_MENU] is the
* app's own source of truth for gating, and a second copy of a security-relevant
* value that drifts silently is worth more than it costs. This table carries the
* mapping and nothing else.
*
* Not every row maps to something the app shows in its drawer, and that is the
* design rather than an omission — see [WEB_PATH_TO_ROUTE].
*/
/** One row of the website's public nav: its path, and the app route it opens. */
data class WebNavPath(val path: String, val route: String)
/**
* The website's public nav in **its** order, mapped to app routes.
*
* The order is load-bearing, not decorative: a stored `order` is an index into
* *this* list (the admin's editor writes the position a row holds on the web), so
* a row the admin never moved has to take its key from the same number line or
* explicit and implicit keys would be incomparable. See `NavOverrides.kt`.
*/
val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
WebNavPath("/", Routes.HOME),
WebNavPath("/site/news", Routes.NEWS),
// The app's News screen carries all four categories as tabs, so these three
// have a route but no drawer row of their own — see the note below.
WebNavPath("/site/screenshots", Routes.news(PostCategory.SCREENSHOTS)),
WebNavPath("/site/five-on-friday", Routes.news(PostCategory.FIVE_ON_FRIDAY)),
WebNavPath("/site/newsletter", Routes.news(PostCategory.NEWSLETTER)),
WebNavPath("/wiki", Routes.WIKI),
WebNavPath("/site/shard", Routes.SHARD),
// Behind the Shard hub in the app, deliberately — no drawer row either.
WebNavPath("/site/champs", Routes.SHARD_CHAMPS),
WebNavPath("/site/guilds", Routes.SHARD_GUILDS),
WebNavPath("/site/governors", Routes.SHARD_GOVERNORS),
WebNavPath("/site/houses", Routes.SHARD_HOUSES),
WebNavPath("/site/rules", Routes.SHARD_RULES),
WebNavPath("/site/atlas", Routes.ATLAS),
WebNavPath("/site/leaderboards", Routes.SHARD_LEADERBOARDS),
WebNavPath("/site/market", Routes.SHARD_MARKET),
WebNavPath("/site/about", Routes.page("about")),
)
/**
* The same table as a lookup.
*
* **A mapped route is not the same thing as a drawer row.** Seven of these paths
* resolve to a screen the app reaches some other way: the three news categories
* are tabs on one News screen, and champs / guilds / governors / houses sit behind
* the Shard hub because that is the better shape on a phone. An override for one
* of them is **ignored** — §6.1's rule is that a nav override may never introduce
* navigation, and the hub is a design decision, not an accident to correct. The
* merge enforces that by intersecting with [APP_MENU]; nothing here needs to know
* which rows those are.
*
* The mapping still exists for all sixteen because phase 6's added links resolve
* an admin-authored path against the same table, and *there* a category tab or a
* hub board is a perfectly good destination — the admin asked for it by path.
*/
val WEB_PATH_TO_ROUTE: Map<String, String> =
WEBSITE_PUBLIC_NAV.associate { it.path to it.route }
/**
* Each app route's index in the website's own nav order — the sort key a row the
* admin never moved takes, so it lands on the same number line as a stored
* `order`. All sixteen routes are distinct, so this loses nothing.
*/
internal val WEB_ROUTE_ORDER: Map<String, Int> =
WEBSITE_PUBLIC_NAV.withIndex().associate { (index, row) -> row.route to index }
/**
* The app route a website nav path opens, or null when the app has no screen for
* it. A trailing slash is tolerated (`/wiki/` is `/wiki`) since a hand-edited
* settings row may carry one; the root path is left alone.
*/
fun appRouteForWebPath(path: String?): String? = WEB_PATH_TO_ROUTE[normalizeWebPath(path)]
/** `/wiki/` → `/wiki`, blank → null, and `/` left alone. */
private fun normalizeWebPath(path: String?): String? {
val trimmed = path?.trim().orEmpty()
if (trimmed.isEmpty()) return null
val normalized = if (trimmed.length > 1) trimmed.trimEnd('/') else trimmed
return normalized.ifEmpty { "/" }
}
/**
* The website's top-level paths that are **not** CMS pages.
*
* The site serves its CMS pages from a top-level `/<slug>` (React Router ranks its
* static routes above that dynamic one), which is what lets [resolveWebPath]'s
* last rule open an admin-authored page natively. These are the segments that rule
* must not swallow: the SPA's own sections, and the two server mounts. A link to
* one of them hands off to the browser, which is where they actually live.
*/
private val RESERVED_TOP_LEVEL = setOf(
"admin", "account", "player", "site", "wiki", "invite", "preview", "api", "uploads",
)
/**
* The app route an **arbitrary** website path opens, or null when the app has no
* screen for it and the link must hand off to a Custom Tab (§6.3).
*
* [appRouteForWebPath] answers for the sixteen paths the *nav* is built from; this
* answers for a path an admin typed into an added link, which may name any page on
* the site. It is the app's read of the site's own route table, and like the table
* above it is cross-repo coupling kept in one file — quoted here for the same
* reason, from `website/client/src/App.jsx`:
*
* ```jsx
* <Route path="/" element={<Portal />} />
* <Route path="/site/news" element={<News />} />
* <Route path="/site/screenshots" element={<Screenshots />} />
* <Route path="/site/five-on-friday" element={<FiveOnFriday />} />
* <Route path="/site/newsletter" element={<Newsletter />} />
* <Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
* <Route path="/site/about" element={<About />} />
* <Route path="/site/status" element={<Status />} />
* <Route path="/site/shard" element={<Shard />} />
* <Route path="/site/shard/activity" element={<ShardActivity />} />
* ... /site/champs, /guilds, /governors, /houses, /rules, /leaderboards, /market
* <Route path="/site/atlas" element={<Atlas />} />
* <Route path="/site/atlas/:slug" element={<AtlasCreature />} />
* <Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
* <Route path="/wiki" element={<Wiki />} />
* <Route path="/wiki/:slug" element={<WikiArticle />} />
* // CMS pages: top-level /:slug, matched only after the named routes above
* <Route path="/:slug" element={<CmsPage />} />
* ```
*
* Note what is *not* in it: no `/site/news/<id>` (a news item renders on its
* category page; the newsletter's is the site's one post-detail route), no
* `/page/<slug>`, and no `/contact` — the app's contact form is app-only (§6.2).
*
* ```
* / → HOME
* /site/news → NEWS
* /site/{screenshots,five-on-friday,newsletter}
* → NEWS, that category's tab
* /site/newsletter/<id> → POST (the site's one post-detail route)
* /wiki → WIKI
* /wiki/<slug> → WIKI_PAGE
* /site/<shard surface> → the mapped shard route (§6.2)
* /site/atlas/<slug> → ATLAS_CREATURE
* /site/market/vendors/<serial> → SHARD_MARKET_VENDOR
* /site/about → PAGE("about")
* /<slug> → PAGE(slug), unless <slug> is reserved
* anything else → null, i.e. the Custom Tab
* ```
*
* **A path carrying a query or a fragment hands off**, whatever its route part
* says. No app route takes either, so a native match would quietly drop what the
* admin wrote; the browser honors it exactly.
*
* Resolving a path is not the same as being allowed to see the screen behind it.
* A link to `/site/market` on a shard that does not publish the market lands on
* the Market screen's honest "not published here" state, which is what typing the
* URL on the web does too (§6.3).
*/
fun resolveWebPath(path: String?): String? {
val normalized = normalizeWebPath(path) ?: return null
if (normalized.any { it == '?' || it == '#' }) return null
WEB_PATH_TO_ROUTE[normalized]?.let { return it }
if (!normalized.startsWith("/")) return null
// Blank segments ("/site//news") mean a malformed path, not a slug.
val segments = normalized.removePrefix("/").split('/')
if (segments.any { it.isBlank() }) return null
return when {
segments.size == 1 -> segments[0].takeIf { it !in RESERVED_TOP_LEVEL }?.let(Routes::page)
segments[0] == "wiki" && segments.size == 2 -> Routes.wikiPage(segments[1])
segments[0] != "site" -> null
segments.size == 3 && segments[1] == "newsletter" ->
Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2])
segments.size == 3 && segments[1] == "atlas" -> Routes.atlasCreature(segments[2])
segments.size == 4 && segments[1] == "market" && segments[2] == "vendors" ->
Routes.marketVendor(segments[3])
else -> null
}
}

View File

@@ -0,0 +1,239 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.doubleOrNull
/**
* The drawer as a one-level tree: the coded menu, plus the **sections** an admin
* grouped rows into and the **links** they added of their own (THEMING_AND_NAV.md
* §6.3). The Kotlin counterpart of the website's `buildPublicNav` + `pruneNav`.
*
* The public nav is the one nav an admin can restructure rather than only reorder,
* and §6.1's invariant survives that structurally rather than by vigilance: a
* coded row is still keyed by a website path the app's own table declares, so an
* override still cannot invent a destination or touch a gate, while everything
* that *can* name an arbitrary path lives in [NavNode.Link], where the path rule
* is applied and the result is resolved through [resolveWebPath].
*
* An added link carries no gate and needs none — the screen behind it enforces its
* own access, so a link to somewhere this caller cannot reach lands on that
* screen's own honest state, exactly as typing the URL on the web does.
*/
sealed interface NavNode {
/** A coded [MenuEntry], relabeled/reordered by the merge but never re-gated. */
data class Item(val entry: MenuEntry) : NavNode
/**
* An admin-authored link to a page on this site.
*
* @param path the stored website path, already validated — this is what a
* Custom Tab opens, resolved against the site's base URL
* @param route the app route [path] maps to, or null when the app has no
* screen for it and the link must hand off (§6.3)
*/
data class Link(
val id: String,
val label: String,
val path: String,
val route: String?,
) : NavNode
/**
* A drawer group: its [label] as a header, its [items] beneath it.
*
* The website renders these as click-to-open dropdowns; a drawer is already a
* vertical list, so the app renders the group open (§6.3). Never empty — see
* [pruneNav].
*/
data class Section(
val id: String,
val label: String,
val items: List<NavNode>,
) : NavNode
}
/** A usable `sections` entry. */
private data class SectionSpec(val id: String, val label: String, val order: Double?)
/** A usable `links` entry, with its section already checked against the stored ones. */
private data class LinkSpec(
val id: String,
val label: String,
val to: String,
val order: Double?,
val section: String?,
)
/** One node waiting to be placed: its sort key, and whether that key was stored. */
private data class Placed(val node: NavNode, val section: String?, val key: Double, val explicit: Boolean)
/**
* Characters that must never appear in a stored link path. The same rule the
* website applies on read: a value that would leave the origin, or carry markup
* into a link, is dropped rather than rendered.
*/
private val FORBIDDEN_IN_PATH = Regex("""[\s<>"'\\]""")
// Forgiving, like every other read in M12: an entry that is not usable is dropped
// and its neighbours kept. A repeated id is dropped too — the first wins, since
// the id is what a link's identity in the drawer is.
private fun readSections(raw: List<JsonObject>): List<SectionSpec> {
val seen = mutableSetOf<String>()
return raw.mapNotNull { section ->
val id = section.stringOrNull("id") ?: return@mapNotNull null
val label = section.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
if (!seen.add(id)) return@mapNotNull null
SectionSpec(id = id, label = label, order = section.orderOrNull())
}
}
private fun readLinks(raw: List<JsonObject>, knownSections: Set<String>): List<LinkSpec> {
val seen = mutableSetOf<String>()
return raw.mapNotNull { link ->
val id = link.stringOrNull("id") ?: return@mapNotNull null
val label = link.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
val to = link.stringOrNull("to") ?: return@mapNotNull null
if (!to.startsWith("/") || to.startsWith("//") || FORBIDDEN_IN_PATH.containsMatchIn(to)) {
return@mapNotNull null
}
if (!seen.add(id)) return@mapNotNull null
LinkSpec(
id = id,
label = label,
to = to,
order = link.orderOrNull(),
// A link naming a section that does not exist is a top-level link, not
// a dropped one: the admin's destination is still good.
section = link.stringOrNull("section")?.takeIf { it in knownSections },
)
}
}
private fun JsonObject.stringOrNull(key: String): String? =
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
private fun JsonObject.orderOrNull(): Double? =
(this["order"] as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull?.takeIf { it.isFinite() }
// Two tie-breaks, the web's and phase 5's: an explicit order beats a coincidental
// index (the admin said "first", so first), and two explicit orders keep
// declaration order, because the sort is stable.
private fun List<Placed>.place(): List<NavNode> =
sortedWith(compareBy<Placed> { it.key }.thenByDescending { it.explicit }).map { it.node }
/**
* The coded menu with the admin's `nav_public` applied in full: relabeled,
* reordered and hidden as phase 5 already did, plus grouped into sections and
* joined by added links.
*
* With no sections and no links this **is** phase 5 — [applyNavOverrides] answers,
* so an untouched instance still gets [APP_MENU] back by identity and AC-1's proof
* is unchanged (§2). The tree build only runs when the admin actually created
* structure.
*
* @param base the coded menu — the only source of `route`, `access` and `feature`
* @param navPublic the parsed `nav_public` row, or null when the admin never
* edited the nav
*/
fun buildNavTree(base: List<MenuEntry>, navPublic: JsonObject?): List<NavNode> {
val sections = readSections(sectionsOf(navPublic))
val links = readLinks(linksOf(navPublic), sections.map { it.id }.toSet())
if (sections.isEmpty() && links.isEmpty()) {
return applyNavOverrides(base, navPublic).map { NavNode.Item(it) }
}
val knownSections = sections.map { it.id }.toSet()
val coded = base.map { it.route }.toSet()
// Keyed by app route, and only for a route the coded menu declares — the same
// narrowing as the flat merge, so an override for a path the app maps but does
// not surface (a news category tab, a Shard hub board) is dropped here too.
val overrides = buildMap {
for ((path, raw) in itemsOf(navPublic)) {
val route = appRouteForWebPath(path) ?: continue
if (route !in coded) continue
val override = cleanOverride(raw)
// A section the stored value never declares is no section at all.
val section = override.section?.takeIf { it in knownSections }
if (!override.isEmpty || section != null) put(route, override.copy(section = section))
}
}
// The app's own surfaces (Contact, Account, the player groups, the staff rows)
// have no website counterpart to be reordered against or grouped under, so they
// keep their coded order after the public block — where they already sit (§6.2).
val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }
val placed = mutableListOf<Placed>()
for (entry in mapped) {
val override = overrides[entry.route]
if (override?.hidden == true) continue
placed += Placed(
node = NavNode.Item(override?.label?.let { entry.copy(label = it) } ?: entry),
section = override?.section,
// An untouched row's key is its index in the WEBSITE's nav, so stored
// and implicit keys sit on one number line (phase 5).
key = override?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble(),
explicit = override?.order != null,
)
}
// An admin-created entity with no stored order appends after the coded rows, in
// creation order, rather than jumping to the front on a 0 default.
var next = WEBSITE_PUBLIC_NAV.size
for (section in sections) {
placed += Placed(
node = NavNode.Section(section.id, section.label, emptyList()),
section = null,
key = section.order ?: (next++).toDouble(),
explicit = section.order != null,
)
}
for (link in links) {
placed += Placed(
node = NavNode.Link(link.id, link.label, link.to, resolveWebPath(link.to)),
section = link.section,
key = link.order ?: (next++).toDouble(),
explicit = link.order != null,
)
}
val top = placed.filter { it.node is NavNode.Section || it.section == null }.place()
return top.map { node ->
if (node !is NavNode.Section) {
node
} else {
node.copy(items = placed.filter { it.section == node.id }.place())
}
} + appOnly.map { NavNode.Item(it) }
}
/**
* The tree with this caller's gates applied — and a section they empty dropped.
*
* This is the boundary, and it runs **after** [buildNavTree], never before: an
* override is presentation, so a row it relabels, moves or marks `hidden: false`
* is still shown only if [isVisible] says so (§6.1, AC-3).
*
* The empty-section case is the one with real correctness risk and the reason the
* rule is ported rather than left to the drawer: a group whose every member is
* withheld by the caller's role or by the shard's visibility config must not draw
* as a header with nothing under it.
*
* Links are not gated — see [NavNode].
*
* @param isVisible the caller's own predicate, applied to coded items only, so
* this file stays ignorant of sessions and shard features
*/
fun pruneNav(tree: List<NavNode>, isVisible: (MenuEntry) -> Boolean): List<NavNode> {
fun keep(node: NavNode) = node !is NavNode.Item || isVisible(node.entry)
return tree.mapNotNull { node ->
when (node) {
is NavNode.Section -> node.copy(items = node.items.filter(::keep)).takeIf { it.items.isNotEmpty() }
else -> node.takeIf { keep(it) }
}
}
}

View File

@@ -3,6 +3,8 @@
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.data.repository.ContentRepository
/**
* Navigation destinations for the M1 public surface (PLAN.md §5). Routes are
* plain strings for Navigation-Compose; argument-bearing routes expose a
@@ -14,6 +16,19 @@ object Routes {
const val WIKI = "wiki"
const val CONTACT = "contact"
/**
* The News hub's NavHost pattern: [NEWS] plus an optional category, so a link
* to one of the website's three category pages can land on the matching tab
* (THEMING_AND_NAV.md §6.2). Navigating to plain [NEWS] matches this pattern
* with no argument and opens the default tab, so every existing call site —
* the drawer, [forStream] — is unaffected.
*
* Declared beside [NEWS] rather than replacing it because the two are used for
* different things: this is what `composable()` and `destination.route` speak,
* [NEWS] is what callers navigate to.
*/
const val NEWS_ROUTE = "news?category={category}"
/** Native login (§4.1) and the signed-in account surface (§5). */
const val LOGIN = "login"
const val ACCOUNT = "account"
@@ -34,6 +49,18 @@ object Routes {
const val SHARD_GOVERNORS = "shard/governors"
const val SHARD_HOUSES = "shard/houses"
/**
* Protocol 3.0 shard content (M11), each gated by its own visibility feature. The
* atlas is not under `shard/` on the wire (`/public/atlas`) because it is static
* content rather than live state, but it is a peer of these in the app's nav.
*/
const val SHARD_RULES = "shard/rules"
const val SHARD_LEADERBOARDS = "shard/leaderboards"
const val SHARD_MARKET = "shard/market"
const val SHARD_MARKET_VENDOR = "shard/market/{serial}"
const val ATLAS = "atlas"
const val ATLAS_CREATURE = "atlas/{slug}"
/** Player game-data groups (§6.3, player-only). Distinct from the public shard boards. */
const val PLAYER_CHARACTERS = "player/characters"
const val PLAYER_VENDORS = "player/vendors"
@@ -67,11 +94,24 @@ object Routes {
fun page(slug: String) = "page/$slug"
fun post(categoryUrlSlug: String, idOrSlug: String) = "news/$categoryUrlSlug/$idOrSlug"
/**
* The News hub with [category] preselected. Takes the enum rather than a slug
* so an unmapped category cannot reach the NavHost — the screen's tabs are the
* enum's entries, and a slug it doesn't know would select nothing.
*/
fun news(category: ContentRepository.PostCategory) = "news?category=${category.urlSlug}"
fun wikiPage(slug: String) = "wiki/$slug"
/** The character-sheet route for an in-game serial (e.g. "0x24C"). */
fun playerChar(serial: String) = "player/char/$serial"
/** One player vendor's shop, by in-game (hex) serial. */
fun marketVendor(serial: String) = "shard/market/$serial"
/** One creature's atlas page, by slug. */
fun atlasCreature(slug: String) = "atlas/$slug"
/**
* The in-app destination a tapped push notification deep-links to (§11, M7
* Part 2 work item 7). Maps a stream id to the screen that shows its content;

View File

@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Tab
@@ -29,6 +28,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/** News hub with category tabs and a post list (PLAN.md §6.1). */
@Composable
@@ -82,7 +82,7 @@ private fun PostList(
@Composable
private fun PostRow(post: PostDto, onClick: () -> Unit) {
Card(
ShardCard(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)

View File

@@ -3,12 +3,14 @@
*/
package com.runicgateway.app.ui.news
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.repository.ContentRepository
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.Routes
import com.runicgateway.app.ui.toUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
@@ -21,9 +23,15 @@ import javax.inject.Inject
@HiltViewModel
class NewsViewModel @Inject constructor(
private val contentRepository: ContentRepository,
savedStateHandle: SavedStateHandle,
) : ViewModel() {
private val _category = MutableStateFlow(PostCategory.NEWS)
// Which tab to open on. Absent — every route into this screen except an
// admin's nav override or added link (THEMING_AND_NAV.md §6.2) — is the
// default feed, and so is a slug the app doesn't know.
private val _category = MutableStateFlow(
PostCategory.fromUrlSlug(savedStateHandle[Routes.Args.CATEGORY]) ?: PostCategory.NEWS,
)
val category: StateFlow<PostCategory> = _category.asStateFlow()
private val _state = MutableStateFlow<UiState<List<PostDto>>>(UiState.Loading)

View File

@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@@ -26,6 +25,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.CharPointsDto
import com.runicgateway.app.data.api.dto.CharProfileDto
import com.runicgateway.app.data.api.dto.CharStatsDto
import com.runicgateway.app.data.api.dto.EquipmentDto
@@ -36,6 +36,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatBar
import kotlinx.serialization.json.jsonPrimitive
@@ -71,6 +72,7 @@ private fun CharacterSheet(char: CharProfileDto, modifier: Modifier = Modifier)
char.stats?.let { AttributesBlock(it) }
char.stats?.resist?.let { ResistancesBlock(it) }
SkillsBlock(char.skills)
PointsBlock(displayPoints(char))
EquipmentBlock(char.equipment)
}
}
@@ -215,6 +217,47 @@ private fun SkillsBlock(skills: List<SkillDto>) {
}
}
/**
* Loyalty & points standings (Protocol 3.0 §7.3). Renders nothing at all for a
* character that has earned nothing anywhere, which is a normal state.
*
* Only a system with a real cap gets a meter: an uncapped score
* ([CharPointsDto.maxPoints] `0`, the common case on a real shard) has nothing to be
* a fraction of, and a full-width bar would imply a completion that doesn't exist.
*/
@Composable
private fun PointsBlock(points: List<CharPointsDto>) {
if (points.isEmpty()) return
SheetCard(R.string.player_char_points) {
points.forEach { entry ->
val cap = entry.cap
val score = entry.points ?: 0L
Column(Modifier.padding(vertical = 5.dp)) {
Row(
Modifier.fillMaxWidth().padding(bottom = 4.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
// `rank` is absent unless the shard opts in; absent and
// "unranked" are different, so the suffix only appears when sent.
entry.rank?.let { stringResource(R.string.player_char_points_ranked, pointsLabel(entry), it) }
?: pointsLabel(entry),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
cap?.let { stringResource(R.string.player_char_points_of, score, it) } ?: score.toString(),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Medium,
)
}
if (cap != null) StatBar((score.toDouble() / cap).coerceIn(0.0, 1.0).toFloat())
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun EquipmentBlock(equipment: List<EquipmentDto>) {
@@ -223,7 +266,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
equipment.forEach { item ->
Column(Modifier.fillMaxWidth().padding(vertical = 6.dp)) {
Text(
item.layer ?: stringResource(R.string.player_char_item),
item.label ?: stringResource(R.string.player_char_item),
style = MaterialTheme.typography.bodyLarge,
)
val meta = listOfNotNull(
@@ -246,7 +289,7 @@ private fun EquipmentBlock(equipment: List<EquipmentDto>) {
@Composable
private fun SheetCard(titleRes: Int, content: @Composable () -> Unit) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(titleRes), style = MaterialTheme.typography.titleMedium)
content()
@@ -266,23 +309,58 @@ internal fun formatSkill(value: Double): String =
/**
* The human-readable title chips for a [TitlesDto] (parity with the website's
* `CharacterSheet.jsx#displayTitles`): fame/karma, skill title, and the selected
* reward title — but only if it is a literal string, not a bare cliloc number
* (the app ships no cliloc table). De-duplicated, blanks dropped.
* reward title.
*
* Reward entries arrive as either a literal or a cliloc number in string form. The
* server now resolves the numeric ones into `rewardResolved`, a **parallel** array
* (see `docs/website/CLILOCS.md`), so the mapping below is index-preserving: an entry
* that didn't resolve becomes null and is skipped, but must not shift the `selected`
* index onto its neighbour. A number with no resolution is still skipped rather than
* rendered as a raw id, which is also the whole behavior on a shard that configures
* no cliloc table.
*
* Falling back to the first title that resolved (rather than showing nothing) matters
* when the *selected* one is the unresolved entry. De-duplicated, blanks dropped.
*/
internal fun displayTitles(titles: TitlesDto?): List<String> {
if (titles == null) return emptyList()
val out = mutableListOf<String>()
titles.fameKarma?.let { out.add(it) }
titles.skill?.let { out.add(it) }
val reward = titles.reward
val sel = titles.selected ?: -1
val candidate = when {
sel in reward.indices -> reward[sel]
else -> reward.firstOrNull { it.isNotBlank() && !it.all(Char::isDigit) }
val reward = titles.reward.mapIndexed { i, raw ->
titles.rewardResolved.getOrNull(i)
?: raw.takeUnless { it.isBlank() || it.all(Char::isDigit) }
}
if (candidate != null && candidate.isNotBlank() && !candidate.all(Char::isDigit)) out.add(candidate)
val candidate = reward.getOrNull(titles.selected ?: -1) ?: reward.firstNotNullOfOrNull { it }
if (!candidate.isNullOrBlank()) out.add(candidate)
return out.filter { it.isNotBlank() }.distinct()
}
/**
* A point system's display name: the shard's own [CharPointsDto.nameString] when it
* has one, else the humanised `PointsType` key.
*
* The fallback is the PRIMARY path, not a defensive nicety — most systems name
* themselves with a cliloc, so `nameString` comes back null for four of five boards
* on a real shard (`docs/link/v3.md` §7.5). Parity with the website's
* `humanisePoints`.
*/
internal fun pointsLabel(entry: CharPointsDto): String {
entry.nameString?.takeIf { it.isNotBlank() }?.let { return it }
val key = entry.system.orEmpty()
return key
.replace(Regex("([a-z0-9])([A-Z])"), "$1 $2")
.replaceFirstChar { it.uppercaseChar() }
}
/**
* The points block, best standing first, dropping systems the character has no score
* in. Guarded for an older shard plugin that sends no `points` block at all.
*/
internal fun displayPoints(char: CharProfileDto): List<CharPointsDto> =
char.points
.filter { (it.points ?: 0L) > 0L }
.sortedByDescending { it.points ?: 0L }
private fun jsonText(element: kotlinx.serialization.json.JsonElement): String =
runCatching { element.jsonPrimitive.content }.getOrElse { element.toString() }

View File

@@ -13,7 +13,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -39,6 +38,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
@@ -90,7 +90,7 @@ fun CharactersScreen(
@Composable
private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
var code by rememberSaveable { mutableStateOf("") }
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.player_link_title), style = MaterialTheme.typography.titleMedium)
Text(
@@ -121,7 +121,7 @@ private fun LinkCard(state: CharactersViewModel.State, viewModel: CharactersView
private fun CreateAccountCard(state: CharactersViewModel.State, viewModel: CharactersViewModel) {
var account by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.player_create_title), style = MaterialTheme.typography.titleMedium)
OutlinedTextField(
@@ -204,7 +204,7 @@ private fun RosterError(kind: ErrorKind, onRetry: () -> Unit) {
@Composable
private fun CharRow(char: RosterCharDto, onOpenChar: (String) -> Unit) {
Card(
ShardCard(
Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)

View File

@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -28,6 +27,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/**
* The player's own houses with home/decay status (PLAN.md §6.3), text-only. An
@@ -60,7 +60,7 @@ fun MyHousesScreen(
@Composable
private fun HouseCard(house: PlayerHouseDto) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth()) {
Text(

View File

@@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -31,6 +30,7 @@ import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
import java.text.DateFormat
import java.util.Date
@@ -79,7 +79,7 @@ fun VendorsScreen(
@Composable
private fun SalesCard(sales: UiState<List<VendorSaleDto>>) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(stringResource(R.string.player_sales_title), style = MaterialTheme.typography.titleMedium)
when (sales) {
@@ -185,7 +185,7 @@ private fun VendorError(kind: ErrorKind, onRetry: () -> Unit) {
@Composable
private fun VendorCard(vendor: VendorDto) {
Card(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
ShardCard(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Column(Modifier.padding(16.dp)) {
Text(
vendor.shopName ?: stringResource(R.string.player_vendor_fallback),

View File

@@ -8,6 +8,8 @@ import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.ShardFeaturesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -23,10 +25,28 @@ import javax.inject.Inject
class SessionViewModel @Inject constructor(
sessionManager: SessionManager,
private val authRepository: AuthRepository,
shardFeaturesRepository: ShardFeaturesRepository,
) : ViewModel() {
val session: StateFlow<Session> = sessionManager.state
/**
* Which shard features this viewer may reach (M11). Held here beside [session]
* because it answers the same question for the same consumer: what the shared
* menu reveals. Role and feature config are independent gates — see
* [com.runicgateway.app.ui.navigation.visibleEntries].
*/
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
init {
// The answer is per-viewer, so it is re-resolved on every session change.
// A StateFlow conflates equal values, so a resume revalidation that returns
// the same user does not refetch — only a real sign-in/out/role change does.
viewModelScope.launch {
session.collect { shardFeaturesRepository.refresh() }
}
}
/** Re-validate the cached role against the backend on app resume. */
fun revalidate() {
viewModelScope.launch { authRepository.revalidate() }

View File

@@ -0,0 +1,301 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasPlaceDto
import com.runicgateway.app.data.api.dto.AtlasSpawnerDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* The spawn atlas / bestiary (PLAN.md §9 M11): "where do I find X".
*
* The whole point of the feature is the placement transform the server does — a spawn
* at 5411,1234 becomes *"Despise, Felucca"* — so a row leads with where a creature is
* found, not with coordinates.
*/
@Composable
fun AtlasScreen(
onOpenCreature: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: AtlasViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val query by viewModel.query.collectAsStateWithLifecycle()
Column(modifier.fillMaxSize()) {
OutlinedTextField(
value = query,
onValueChange = viewModel::onQueryChange,
label = { Text(stringResource(R.string.atlas_search_label)) },
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { viewModel.search() }),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
)
when (val s = state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
is UiState.Success -> {
if (s.data.creatures.isEmpty()) {
EmptyView(stringResource(R.string.atlas_empty))
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
) {
items(s.data.creatures, key = { it.slug.orEmpty() }) { creature ->
CreatureCard(creature, onOpenCreature)
}
}
}
}
}
}
}
@Composable
private fun CreatureCard(creature: AtlasCreatureDto, onOpenCreature: (String) -> Unit) {
val slug = creature.slug
ShardCard(
Modifier
.fillMaxWidth()
.then(if (slug != null) Modifier.clickable { onOpenCreature(slug) } else Modifier),
) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = creature.name ?: slug.orEmpty(),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
// `points` is a COUNT of spawners on this route; `spawners` is the list,
// and only the detail route sends it.
creature.points?.let {
Text(
text = pluralStringResource(R.plurals.atlas_spawner_count, it, it),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
facetSummary(creature)?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/** One creature: every spawner, where it stands, and what shares its spawns. */
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AtlasCreatureScreen(
slug: String,
modifier: Modifier = Modifier,
viewModel: AtlasCreatureViewModel = hiltViewModel(),
) {
LaunchedEffect(slug) { viewModel.load(slug) }
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::retry, modifier = modifier)
is UiState.Success -> {
val creature = s.data
LazyColumn(
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
) {
item {
Column {
Text(
creature.name ?: creature.slug.orEmpty(),
style = MaterialTheme.typography.titleLarge,
)
creature.total?.let {
Text(
stringResource(R.string.atlas_total_alive, it),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
if (creature.facets.isNotEmpty()) {
FlowRow(
Modifier.padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
creature.facets.entries.sortedBy { it.key }.forEach { (facet, count) ->
StatusPill(
text = stringResource(R.string.atlas_facet_count, facet, count),
tone = PillTone.Neutral,
)
}
}
}
}
}
// The aggregate comes first: "where is it" is the question, and the
// individual coordinates below are the follow-up. Same ordering as web.
if (creature.places.isNotEmpty()) {
item { SectionLabel(stringResource(R.string.atlas_section_places)) }
items(
creature.places,
key = { "${it.facet.orEmpty()}:${it.label.orEmpty()}" },
) { place ->
PlaceRow(place)
}
}
if (creature.spawners.isNotEmpty()) {
item { SectionLabel(stringResource(R.string.atlas_section_spawners)) }
items(creature.spawners, key = { it.id ?: it.hashCode().toLong() }) { spawner ->
SpawnerRow(spawner)
}
if (creature.spawnersTruncated) {
item {
Text(
stringResource(R.string.atlas_spawners_truncated),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
if (creature.alsoHere.isNotEmpty()) {
item { SectionLabel(stringResource(R.string.atlas_section_also_here)) }
item {
Text(
creature.alsoHere.mapNotNull { it.name ?: it.slug }.joinToString(", "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun PlaceRow(place: AtlasPlaceDto) {
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(
text = placeLabel(place),
style = MaterialTheme.typography.bodyMedium,
)
val meta = listOfNotNull(
place.facet,
place.spawners?.let { pluralStringResource(R.plurals.atlas_spawner_count, it, it) },
place.maxAlive?.let { stringResource(R.string.atlas_place_max_alive, it) },
).joinToString(" · ")
if (meta.isNotBlank()) {
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
@Composable
private fun SpawnerRow(spawner: AtlasSpawnerDto) {
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(
text = spawnerPlace(spawner),
style = MaterialTheme.typography.bodyMedium,
)
val meta = listOfNotNull(
spawner.maxCount?.let { stringResource(R.string.atlas_max_count, it) },
// Seconds, normalised server-side — the raw XmlSpawner values are minutes
// OR seconds per record.
formatRespawn(spawner.minDelay, spawner.maxDelay)
?.let { stringResource(R.string.atlas_respawn, it) },
).joinToString(" · ")
if (meta.isNotBlank()) {
Text(meta, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
/**
* Where a spawner stands, preferring the server's own placement label — the
* point-in-rect transform is what turns a coordinate into "Despise, Felucca" and is
* the reason this feature exists. Falls back through region, landmark, and finally the
* raw coordinates, which is honest rather than useless for the ~17% of spawns that
* resolve to no named place.
*/
internal fun spawnerPlace(spawner: AtlasSpawnerDto): String {
spawner.label?.takeIf { it.isNotBlank() }?.let { return it }
val place = spawner.region ?: spawner.landmark
val facet = spawner.facet
return when {
place != null && facet != null -> "$place, $facet"
place != null -> place
spawner.x != null && spawner.y != null ->
listOfNotNull(facet, "${spawner.x}, ${spawner.y}").joinToString(" ")
else -> facet.orEmpty()
}
}
/**
* The name of an aggregated place. [AtlasPlaceDto.label] is already the server's
* resolved answer and falls back to "Wilderness" there, so the only case left here is
* a place that carried no label at all — then the facet is better than nothing.
*/
internal fun placeLabel(place: AtlasPlaceDto): String =
place.label?.takeIf { it.isNotBlank() } ?: place.facet.orEmpty()
/**
* A creature's facets as one line, most spawners first — "where is it *mostly*" is the
* question a search result answers.
*/
internal fun facetSummary(creature: AtlasCreatureDto): String? {
if (creature.facets.isEmpty()) return null
return creature.facets.entries
.sortedByDescending { it.value }
.joinToString(", ") { it.key }
}

View File

@@ -0,0 +1,125 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toShardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The spawn atlas / bestiary (PLAN.md §9 M11, `docs/link/v3.md` §6): where each
* creature spawns, derived server-side from the shard's own data files.
*
* Static shard **content**, not live state — it does not go offline with the sidecar,
* and it lives under `/public/atlas`, not `/public/shard`. Unlike the shard routes it
* IS site-mode gated, so a site in maintenance withholds it independently.
*/
@HiltViewModel
class AtlasViewModel @Inject constructor(
private val repository: ShardRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<AtlasCreaturePageDto>>(UiState.Loading)
val state: StateFlow<UiState<AtlasCreaturePageDto>> = _state.asStateFlow()
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
private val _facet = MutableStateFlow<String?>(null)
val facet: StateFlow<String?> = _facet.asStateFlow()
/**
* The facets this shard actually has. Discovered from the atlas itself — a shard
* may add, replace or rename facets when its maps change, so nothing here may name
* one (`v3.md` §6.1 R2).
*/
private val _facets = MutableStateFlow<List<String>>(emptyList())
val facets: StateFlow<List<String>> = _facets.asStateFlow()
init {
load()
}
fun onQueryChange(value: String) {
_query.value = value
}
fun onFacetChange(value: String?) {
if (value == _facet.value) return
_facet.value = value
search()
}
fun search() = load()
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
val page = repository.atlasCreatures(query = _query.value, facet = _facet.value)
if (page is ApiResult.Ok && _facets.value.isEmpty()) {
// Only the first successful page needs to establish the filter options;
// a filtered page would otherwise narrow them to its own results.
_facets.value = page.data.creatures
.flatMap { it.facets.keys }
.distinct()
.sorted()
}
_state.value = page.toShardUiState()
}
}
}
/** One creature's detail page: every spawner, and what else shares them. */
@HiltViewModel
class AtlasCreatureViewModel @Inject constructor(
private val repository: ShardRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<AtlasCreatureDto>>(UiState.Loading)
val state: StateFlow<UiState<AtlasCreatureDto>> = _state.asStateFlow()
private var slug: String? = null
fun load(slug: String) {
this.slug = slug
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.atlasCreature(slug).toShardUiState()
}
}
fun retry() {
slug?.let { load(it) }
}
}
/**
* A respawn delay as text. **The API carries SECONDS** — XmlSpawner stores minutes
* except when a delay doesn't divide into whole minutes, and the server's parser
* normalises the two spellings so a `5` is never ambiguous here (`v3.md` §6.3).
*
* Pure, so the unit conversion is unit-tested rather than eyeballed on a page.
*/
internal fun formatRespawn(minSeconds: Int?, maxSeconds: Int?): String? {
val lo = minSeconds ?: maxSeconds ?: return null
val hi = maxSeconds ?: minSeconds ?: return null
return if (lo == hi) humaniseSeconds(lo) else "${humaniseSeconds(lo)}${humaniseSeconds(hi)}"
}
private fun humaniseSeconds(seconds: Int): String = when {
seconds < 60 -> "${seconds}s"
seconds % 60 == 0 -> "${seconds / 60}m"
else -> "${seconds / 60}m ${seconds % 60}s"
}

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/** The champion-spawn board (PLAN.md §6.2), live via SSE deltas. */
@@ -44,7 +44,7 @@ fun ChampsScreen(
@Composable
private fun ChampCard(champ: ChampDto) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth()) {
Text(

View File

@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.ChampDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import com.runicgateway.app.ui.toShardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -49,7 +49,7 @@ class ChampsViewModel @Inject constructor(
board.seed(result.data)
publish()
}
else -> _state.value = result.toUiState()
else -> _state.value = result.toShardUiState()
}
}
}

View File

@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -33,6 +32,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/** The town-governor board (PLAN.md §6.2), live via `city.update`, with per-city history. */
@Composable
@@ -84,7 +84,7 @@ private fun CityCard(
onExpand: () -> Unit,
) {
var expanded by remember { mutableStateOf(false) }
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column {
Column(
Modifier

View File

@@ -11,7 +11,7 @@ import com.runicgateway.app.data.api.dto.GovernorDto
import com.runicgateway.app.data.api.dto.GovernorTermDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import com.runicgateway.app.ui.toShardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -55,7 +55,7 @@ class GovernorsViewModel @Inject constructor(
board.seed(result.data)
publish()
}
else -> _state.value = result.toUiState()
else -> _state.value = result.toShardUiState()
}
}
}

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -20,6 +19,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.ui.components.ShardCard
/** The guild board (PLAN.md §6.2), live via SSE deltas. */
@Composable
@@ -42,7 +42,7 @@ fun GuildsScreen(
@Composable
private fun GuildCard(guild: GuildDto) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth()) {
Text(

View File

@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.GuildDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import com.runicgateway.app.ui.toShardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -50,7 +50,7 @@ class GuildsViewModel @Inject constructor(
board.seed(result.data)
publish()
}
else -> _state.value = result.toUiState()
else -> _state.value = result.toShardUiState()
}
}
}

View File

@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -21,6 +20,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/** The public "falling houses" (IDOC) board (PLAN.md §6.2), live via `house.decay`. */
@@ -44,7 +44,7 @@ fun HousesScreen(
@Composable
private fun HouseCard(house: HouseDto) {
Card(Modifier.fillMaxWidth()) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth()) {
Text(

View File

@@ -10,7 +10,7 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.HouseDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import com.runicgateway.app.ui.toShardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -50,7 +50,7 @@ class HousesViewModel @Inject constructor(
board.seed(result.data)
publish()
}
else -> _state.value = result.toUiState()
else -> _state.value = result.toShardUiState()
}
}
}

View File

@@ -0,0 +1,169 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.PointsEntryDto
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
/**
* The points/loyalty leaderboards (PLAN.md §9 M11), one card per system, live via
* `points.board` frames.
*/
@Composable
fun LeaderboardsScreen(
brand: BrandDto? = null,
modifier: Modifier = Modifier,
viewModel: LeaderboardsViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val connected by viewModel.connected.collectAsStateWithLifecycle()
LiveBoardScreen(
emptyMessage = stringResource(R.string.leaderboards_empty),
state = state,
connected = connected,
onRetry = viewModel::load,
key = { it.system.orEmpty() },
modifier = modifier,
) { board -> BoardCard(board, placeholderName(brand)) }
}
@Composable
private fun BoardCard(board: PointsBoardDto, placeholderName: String) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = boardLabel(board),
style = MaterialTheme.typography.titleMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
board.players?.let {
Text(
text = stringResource(R.string.leaderboards_players, it),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// A cap is worth stating only when there is one; most systems on a real
// shard are uncapped (maxPoints 0), and "/ 0" would be nonsense.
board.cap?.let {
SectionLabel(stringResource(R.string.leaderboards_cap, it))
}
if (board.top.isEmpty()) {
// A board nobody has scored on still gets a row, so the page reads as a
// set of standings waiting to be filled rather than a stack of blanks.
// It is deliberately NOT shaped like an entry — no rank, no score, the
// instance's own name — because a placeholder that looked like a real
// standing would be a fabricated one. The first real entry replaces it.
HorizontalDivider(Modifier.padding(vertical = 8.dp))
Row(
Modifier.fillMaxWidth().padding(vertical = 3.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = placeholderName,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.leaderboards_no_score),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
stringResource(R.string.leaderboards_board_empty),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
} else {
HorizontalDivider(Modifier.padding(vertical = 8.dp))
board.top.forEach { entry -> EntryRow(entry) }
}
}
}
}
@Composable
private fun EntryRow(entry: PointsEntryDto) {
Row(
Modifier.fillMaxWidth().padding(vertical = 3.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(
text = stringResource(
R.string.leaderboards_rank_name,
entry.rank ?: 0,
// The character name is admin-configurable — a shard can publish
// standings without naming who holds them, so a nameless rank is a
// valid row rather than a broken one.
entry.name ?: stringResource(R.string.leaderboards_hidden_name),
),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = (entry.points ?: 0L).toString(),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/**
* A board's display name: the shard's own literal when it has one, else the humanised
* `PointsType` key. The fallback is the PRIMARY path — four of five boards on a real
* shard name themselves with a cliloc and send `nameString: null`.
*/
internal fun boardLabel(board: PointsBoardDto): String {
board.nameString?.takeIf { it.isNotBlank() }?.let { return it }
return board.system.orEmpty()
.replace(Regex("([a-z0-9])([A-Z])"), "$1 $2")
.replaceFirstChar { it.uppercaseChar() }
}
/**
* The name to stand in for an empty board: this instance's, falling back to the app
* name — the same resolution the app bar uses, so a shard that publishes no branding
* still reads as *something* rather than as a blank row.
*
* Pure and separate so the fallback order is testable; [BrandDto.name] can be present
* but blank, which is a shard that set the key and left it empty.
*/
@Composable
internal fun placeholderName(brand: BrandDto?): String =
brand?.name?.takeIf { it.isNotBlank() } ?: stringResource(R.string.app_name)

View File

@@ -0,0 +1,101 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.net.ShardStreamEvent
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toShardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The points/loyalty leaderboards (PLAN.md §9 M11, `docs/link/v3.md` §7): one board
* per point currency the shard publishes, each with its top ranks.
*
* Served from the website's own store, so the page renders while the shard is down —
* which matters more here than for live state: these are standings accumulated over
* months, and blanking them during a restart would look like data loss.
*
* Kept live by `points.board` frames, one per system, merged in place by [LiveBoard].
*/
@HiltViewModel
class LeaderboardsViewModel @Inject constructor(
private val repository: ShardRepository,
) : ViewModel() {
private val board = LiveBoard<PointsBoardDto> { it.system.orEmpty() }
private val _state = MutableStateFlow<UiState<List<PointsBoardDto>>>(UiState.Loading)
val state: StateFlow<UiState<List<PointsBoardDto>>> = _state.asStateFlow()
private val _connected = MutableStateFlow(false)
val connected: StateFlow<Boolean> = _connected.asStateFlow()
init {
load()
collectLive()
}
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
when (val result = repository.pointsBoards()) {
is ApiResult.Ok -> {
board.seed(result.data)
publish()
}
else -> _state.value = result.toShardUiState()
}
}
}
private fun collectLive() {
viewModelScope.launch {
repository.liveEvents().collect { event ->
when (event) {
is ShardStreamEvent.Open -> _connected.value = true
is ShardStreamEvent.Closed -> _connected.value = false
is ShardStreamEvent.Frame -> applyFrame(event)
}
}
}
}
private fun applyFrame(frame: ShardStreamEvent.Frame) {
// There is deliberately no `points.remove` on the wire: the system set is fixed
// for a given shard build, the same argument `city.update` makes.
if (frame.kind != "points.board") return
repository.pointsBoardFrame(frame.data)?.let { board.upsert(it) }
if (_state.value is UiState.Success) publish()
}
private fun publish() {
_state.value = UiState.Success(orderBoards(board.values()))
}
}
/**
* Board display order: most-contested first, then by name, so the boards people
* actually compete on lead. Pure, so the ordering is unit-tested.
*
* Boards the shard flags as not player-facing (`showOnGump = false`) are dropped —
* that is the shard's own "is this for players?" signal and the plugin already filters
* on it, so this only guards a shard configured to publish extras.
*/
internal fun orderBoards(boards: Collection<PointsBoardDto>): List<PointsBoardDto> =
boards
.filter { it.showOnGump }
.sortedWith(
compareByDescending<PointsBoardDto> { it.players ?: 0 }
.thenBy { (it.nameString ?: it.system).orEmpty().lowercase() },
)

View File

@@ -0,0 +1,260 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.MarketListingDto
import com.runicgateway.app.data.api.dto.MarketLocationDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
/**
* The shard-wide marketplace (PLAN.md §9 M11): search every player vendor's stock.
*
* The staleness line under the search box is required, not decoration — see
* [MarketViewModel]. Results are listings, so a row names both the item and the shop
* that sells it, and tapping it opens that shop.
*/
@Composable
fun MarketScreen(
onOpenVendor: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: MarketViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
val meta by viewModel.meta.collectAsStateWithLifecycle()
val query by viewModel.query.collectAsStateWithLifecycle()
Column(modifier.fillMaxSize()) {
OutlinedTextField(
value = query,
onValueChange = viewModel::onQueryChange,
label = { Text(stringResource(R.string.market_search_label)) },
singleLine = true,
// Searched on submit rather than per keystroke: this is the site's first
// rate-limited public endpoint.
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { viewModel.search() }),
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
)
meta?.staleAt?.let {
SectionLabel(
text = stringResource(R.string.market_staleness),
modifier = Modifier.padding(horizontal = 16.dp),
)
}
when (val s = state) {
is UiState.Loading -> LoadingView()
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load)
is UiState.Success -> {
if (s.data.listings.isEmpty()) {
EmptyView(stringResource(R.string.market_empty))
} else {
LazyColumn(
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp),
) {
items(s.data.listings, key = { it.serial ?: it.hashCode().toString() }) { listing ->
ListingCard(listing, onOpenVendor)
}
}
}
}
}
}
}
@Composable
private fun ListingCard(listing: MarketListingDto, onOpenVendor: (String) -> Unit) {
val vendorSerial = listing.vendor?.serial
ShardCard(
Modifier
.fillMaxWidth()
.then(if (vendorSerial != null) Modifier.clickable { onOpenVendor(vendorSerial) } else Modifier),
) {
Column(Modifier.padding(16.dp)) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = listingTitle(listing)
?: stringResource(R.string.market_unnamed_item, listing.itemId ?: 0),
style = MaterialTheme.typography.titleSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.market_price, listing.price ?: 0L),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium,
)
}
val shop = listing.vendor?.shopName ?: listing.vendor?.ownerName
if (shop != null) {
Text(
text = shop,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
locationLine(listing.vendor?.location)?.let {
Text(
text = it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/**
* One shop and its stock. The only surface that can answer the two questions a result
* list can't: how much of a truncated shop is published, and where a shop is when the
* shard doesn't say.
*/
@Composable
fun MarketVendorScreen(
serial: String,
modifier: Modifier = Modifier,
viewModel: MarketVendorViewModel = hiltViewModel(),
) {
androidx.compose.runtime.LaunchedEffect(serial) { viewModel.load(serial) }
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::retry, modifier = modifier)
is UiState.Success -> VendorContent(s.data, modifier)
}
}
@Composable
private fun VendorContent(vendor: MarketVendorDto, modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
) {
item {
Column {
Text(
vendor.shopName ?: stringResource(R.string.market_unnamed_shop),
style = MaterialTheme.typography.titleLarge,
)
vendor.ownerName?.let {
Text(
stringResource(R.string.market_owner, it),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
// A gated location is a real answer, not a blank: the shard has
// this shop, it just doesn't publish where it stands.
text = locationLine(vendor.location) ?: stringResource(R.string.market_location_hidden),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (vendor.truncated) {
Text(
text = stringResource(
R.string.market_truncated,
vendor.count ?: vendor.items.size,
vendor.total ?: 0,
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
if (vendor.items.isEmpty()) {
item { Text(stringResource(R.string.market_shop_empty), style = MaterialTheme.typography.bodyMedium) }
} else {
items(vendor.items, key = { it.serial ?: it.hashCode().toString() }) { item ->
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = listingTitle(item) ?: stringResource(R.string.market_unnamed_item, item.itemId ?: 0),
style = MaterialTheme.typography.bodyMedium,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
Text(
text = stringResource(R.string.market_price, item.price ?: 0L),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
/**
* What to call a listing: a player-set name, else the server-resolved cliloc name,
* else null so the caller can fall back to the item id. A shard with no cliloc table
* configured legitimately publishes neither.
*
* A stack shows its count, since "12 × ingot" and "ingot" at the same price are very
* different offers.
*/
internal fun listingTitle(listing: MarketListingDto): String? {
val base = listing.label ?: return null
val amount = listing.amount ?: 1
return if (amount > 1) "$amount × $base" else base
}
/**
* A shop's whereabouts as one line, or null when the shard publishes no location —
* which happens both because an admin gated the field and because the nesting means
* the WHOLE block goes at once, never a half-populated one.
*/
internal fun locationLine(location: MarketLocationDto?): String? {
if (location == null) return null
val place = location.house ?: location.region
val facet = location.map
return when {
place != null && facet != null -> "$place, $facet"
place != null -> place
facet != null -> facet
else -> null
}
}

View File

@@ -0,0 +1,118 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.MarketMetaDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toShardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The shard-wide player-vendor marketplace (PLAN.md §9 M11, `docs/link/v3.md` §8):
* search every shop's stock at once.
*
* **Not live, on purpose.** The `market` feature ships with its SSE fan-out disabled —
* a firehose of full vendor inventories would be the site's biggest bandwidth consumer
* and no screen needs it live — so this is a plain paginated read. It is also the
* first genuinely **rate-limited** public endpoint, which is why the query is applied
* on submit rather than on every keystroke.
*
* The staleness stamp from [meta] is not decoration: the shard sweeps vendors
* round-robin, so a listing can legitimately be a full cycle behind, and a screen that
* implied live prices would send someone to an item that sold twenty minutes ago.
*/
@HiltViewModel
class MarketViewModel @Inject constructor(
private val repository: ShardRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<MarketPageDto>>(UiState.Loading)
val state: StateFlow<UiState<MarketPageDto>> = _state.asStateFlow()
private val _meta = MutableStateFlow<MarketMetaDto?>(null)
val meta: StateFlow<MarketMetaDto?> = _meta.asStateFlow()
private val _query = MutableStateFlow("")
val query: StateFlow<String> = _query.asStateFlow()
private val _sort = MutableStateFlow(ShardRepository.SORT_PRICE_ASC)
val sort: StateFlow<String> = _sort.asStateFlow()
init {
load()
}
fun onQueryChange(value: String) {
// Bounded to what the server accepts, so an over-long query is trimmed here
// rather than bounced as a 400.
_query.value = value.take(MAX_QUERY)
}
fun onSortChange(value: String) {
if (value == _sort.value) return
_sort.value = value
search()
}
/** Run the current query. Called on submit, not per keystroke — this endpoint is rate-limited. */
fun search() = load()
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
// Meta is secondary: the staleness banner and filter options are worth
// having, but a failure there must not blank the results.
_meta.value = (repository.marketMeta() as? ApiResult.Ok)?.data
_state.value = repository.market(
query = _query.value,
sort = _sort.value,
).toShardUiState()
}
}
companion object {
/** The server rejects a longer `q`. */
const val MAX_QUERY = 60
}
}
/**
* One shop and its stock. The only surface that can render the two states a result
* list cannot: a [MarketVendorDto.truncated] shop, and a location an admin has gated
* away — which is a real answer ("the shard doesn't publish where this is") rather
* than an empty coordinate.
*/
@HiltViewModel
class MarketVendorViewModel @Inject constructor(
private val repository: ShardRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<MarketVendorDto>>(UiState.Loading)
val state: StateFlow<UiState<MarketVendorDto>> = _state.asStateFlow()
private var serial: String? = null
fun load(serial: String) {
this.serial = serial
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.marketVendor(serial).toShardUiState()
}
}
fun retry() {
serial?.let { load(it) }
}
}

View File

@@ -0,0 +1,207 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.runicgateway.app.R
import com.runicgateway.app.data.api.dto.RulesetCapsDto
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/**
* The shard ruleset (PLAN.md §9 M11): what this world is configured to do.
*
* Every block renders only when the shard published it — an omitted block means the
* system is off, not that the value is unknown, so an empty section would assert
* something false.
*/
@Composable
fun RulesScreen(
modifier: Modifier = Modifier,
viewModel: RulesViewModel = hiltViewModel(),
) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
is UiState.Success -> {
val ruleset = s.data
// A null body is a successful read of a shard that has never published its
// ruleset — distinct from the feature being switched off, which is an error
// state above.
if (ruleset == null) {
EmptyView(stringResource(R.string.rules_unpublished), modifier)
} else {
RulesetContent(ruleset, modifier)
}
}
}
}
@Composable
private fun RulesetContent(ruleset: RulesetDto, modifier: Modifier = Modifier) {
LazyColumn(
modifier = modifier.fillMaxSize().padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
) {
item {
RuleCard(stringResource(R.string.rules_section_shard)) {
RuleRow(stringResource(R.string.rules_name), ruleset.shard)
RuleRow(stringResource(R.string.rules_expansion), ruleset.expansion)
// `connect` is the ruleset's one admin-configurable field: an operator
// who published an address may still want it behind a login, so its
// absence here is a setting, not a missing value.
RuleRow(stringResource(R.string.rules_connect), ruleset.connect)
}
}
if (ruleset.systems.isNotEmpty()) {
item { SystemsCard(ruleset.systems) }
}
ruleset.caps?.let { caps -> item { CapsCard(caps) } }
item {
val accounts = ruleset.accounts
val housing = ruleset.housing
if (accounts != null || housing != null) {
RuleCard(stringResource(R.string.rules_section_accounts)) {
RuleRow(stringResource(R.string.rules_char_slots), accounts?.charSlots?.toString())
RuleRow(stringResource(R.string.rules_per_ip), accounts?.perIp?.toString())
RuleRow(
stringResource(R.string.rules_house_limit),
housing?.accountHouseLimit?.toString(),
)
}
}
}
ruleset.vendors?.let { vendors ->
item {
RuleCard(stringResource(R.string.rules_section_vendors)) {
RuleRow(
stringResource(R.string.rules_restock_delay),
vendors.restockDelayMinutes?.let { stringResource(R.string.rules_minutes, it) },
)
RuleRow(stringResource(R.string.rules_max_sell), vendors.maxSell?.toString())
}
}
}
ruleset.schedule?.let { schedule ->
item {
RuleCard(stringResource(R.string.rules_section_schedule)) {
RuleRow(
stringResource(R.string.rules_autosave),
schedule.autoSaveFrequencyMinutes?.let { stringResource(R.string.rules_minutes, it) },
)
RuleRow(
stringResource(R.string.rules_autorestart),
formatRestart(
schedule.autoRestartEnabled,
schedule.autoRestartHour,
schedule.autoRestartMinute,
),
)
}
}
}
}
}
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun SystemsCard(systems: Map<String, Boolean>) {
RuleCard(stringResource(R.string.rules_section_systems)) {
FlowRow(
Modifier.padding(top = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
// Sorted so the list is stable across reloads; the wire order is a config
// read order and carries no meaning.
systems.entries.sortedBy { it.key }.forEach { (key, on) ->
StatusPill(
text = humaniseSystem(key),
tone = if (on) PillTone.Success else PillTone.Neutral,
)
}
}
}
}
@Composable
private fun CapsCard(caps: RulesetCapsDto) {
RuleCard(stringResource(R.string.rules_section_caps)) {
// Skill caps arrive in TENTHS (1000 = 100.0). Showing the raw number would read
// as a shard with ten times the usual limit.
RuleRow(stringResource(R.string.rules_skill_cap), caps.skillCap?.let { formatSkillCap(it) })
RuleRow(stringResource(R.string.rules_total_skill_cap), caps.totalSkillCap?.let { formatSkillCap(it) })
RuleRow(stringResource(R.string.rules_stat_cap), caps.stat?.toString())
RuleRow(stringResource(R.string.rules_str_cap), caps.str?.toString())
RuleRow(stringResource(R.string.rules_dex_cap), caps.dex?.toString())
RuleRow(stringResource(R.string.rules_int_cap), caps.int?.toString())
}
}
@Composable
private fun RuleCard(title: String, content: @Composable () -> Unit) {
ShardCard(Modifier.fillMaxWidth()) {
Column(Modifier.padding(16.dp)) {
Text(title, style = MaterialTheme.typography.titleMedium)
content()
}
}
}
/** One label/value line. Renders nothing when the shard published no value. */
@Composable
private fun RuleRow(label: String, value: String?) {
if (value.isNullOrBlank()) return
Row(
Modifier.fillMaxWidth().padding(top = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(label, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(value, style = MaterialTheme.typography.bodyMedium)
}
}
// ── Pure helpers (unit-tested) ───────────────────────────────────────────────
/** `cityLoyalty` → "City loyalty". The systems block is a flat bag of config keys. */
internal fun humaniseSystem(key: String): String = key
.replace(Regex("([a-z0-9])([A-Z])"), "$1 $2")
.replaceFirstChar { it.uppercaseChar() }
/** A skill cap already converted out of tenths: drop the ".0" on whole values. */
internal fun formatSkillCap(value: Double): String =
if (value % 1.0 == 0.0) value.toInt().toString() else "%.1f".format(value)
/** The auto-restart schedule, or null when the shard doesn't run one. */
internal fun formatRestart(enabled: Boolean?, hour: Int?, minute: Int?): String? {
if (enabled != true) return null
if (hour == null) return null
return "%02d:%02d".format(hour, minute ?: 0)
}

View File

@@ -0,0 +1,63 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.core.net.ShardStreamEvent
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toShardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* The shard ruleset (PLAN.md §9 M11, `docs/link/v3.md` §5): what this world is
* configured to do — systems on/off, caps, account and housing limits, the champion
* and Felucca tables, the save/restart schedule.
*
* Two states the screen must tell apart, which is why the success type is nullable:
* a `null` body means the shard has **never published** a ruleset (the plugin is old,
* or `RulesetEnabled=false`), while the feature being switched off is a 404 folded
* into `ErrorKind.FEATURE_UNAVAILABLE`.
*
* Kept live by the `world.ruleset` frame, which the shard re-emits on every sidecar
* reconnect — so a shard that restarts with edited config updates the open screen.
*/
@HiltViewModel
class RulesViewModel @Inject constructor(
private val repository: ShardRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<RulesetDto?>>(UiState.Loading)
val state: StateFlow<UiState<RulesetDto?>> = _state.asStateFlow()
init {
load()
collectLive()
}
fun load() {
_state.value = UiState.Loading
viewModelScope.launch {
_state.value = repository.ruleset().toShardUiState()
}
}
private fun collectLive() {
viewModelScope.launch {
repository.liveEvents().collect { event ->
if (event !is ShardStreamEvent.Frame || event.kind != "world.ruleset") return@collect
// The frame IS the whole ruleset — replace rather than merge. A frame the
// app can't decode is skipped, leaving the loaded copy in place.
repository.rulesetFrame(event.data)?.let { _state.value = UiState.Success(it) }
}
}
}
}

View File

@@ -12,7 +12,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Card
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -32,12 +31,34 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.FeatureCard
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.canSee
import com.runicgateway.app.ui.components.PillTone
import com.runicgateway.app.ui.components.SectionLabel
import com.runicgateway.app.ui.components.ShardCard
import com.runicgateway.app.ui.components.StatusPill
/** Board destinations reachable from the hub. */
enum class ShardBoard { CHAMPS, GUILDS, GOVERNORS, HOUSES }
/**
* Board destinations reachable from the hub, each tagged with the visibility feature
* that governs it (M11). An admin can switch any of these off or raise its audience,
* so the hub's board list is filtered the same way the drawer is — a tile whose
* feature the caller can't see would only lead to a `404`/`403`.
*/
enum class ShardBoard(val feature: String) {
CHAMPS(ShardFeature.CHAMPS),
GUILDS(ShardFeature.GUILDS),
GOVERNORS(ShardFeature.GOVERNORS),
HOUSES(ShardFeature.HOUSES),
}
/**
* The boards this viewer may reach. Pure + side-effect-free so the gating is
* unit-tested without Compose, exactly like `visibleEntries` for the drawer. An
* unknown answer shows every board — the server gates regardless (see [canSee]).
*/
fun visibleBoards(features: ShardFeatures?): List<ShardBoard> =
ShardBoard.entries.filter { canSee(features, it.feature) }
/**
* The Shard hub (PLAN.md §6.2): live connection status, online count + latest
@@ -54,6 +75,7 @@ fun ShardScreen(
val state by viewModel.state.collectAsStateWithLifecycle()
val feed by viewModel.feed.collectAsStateWithLifecycle()
val connected by viewModel.connected.collectAsStateWithLifecycle()
val features by viewModel.shardFeatures.collectAsStateWithLifecycle()
when (val s = state) {
is UiState.Loading -> LoadingView(modifier)
@@ -62,6 +84,7 @@ fun ShardScreen(
hub = s.data,
feed = feed,
connected = connected,
features = features,
onOpenBoard = onOpenBoard,
modifier = modifier,
)
@@ -73,6 +96,7 @@ private fun HubContent(
hub: ShardHub,
feed: List<FeedLine>,
connected: Boolean,
features: ShardFeatures?,
onOpenBoard: (ShardBoard) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -82,7 +106,7 @@ private fun HubContent(
contentPadding = androidx.compose.foundation.layout.PaddingValues(vertical = 16.dp),
) {
item { StatusCard(hub.status, hub.presence?.count) }
item { BoardsCard(onOpenBoard) }
item { BoardsCard(features, onOpenBoard) }
if (hub.online.isNotEmpty()) {
item { SectionHeader(stringResource(R.string.shard_section_staff)) }
@@ -159,14 +183,17 @@ private fun StatusCard(status: ShardStatusDto, presenceCount: Int?) {
}
@Composable
private fun BoardsCard(onOpenBoard: (ShardBoard) -> Unit) {
val boards = listOf(
ShardBoard.CHAMPS to R.string.shard_nav_champs,
ShardBoard.GUILDS to R.string.shard_nav_guilds,
ShardBoard.GOVERNORS to R.string.shard_nav_governors,
ShardBoard.HOUSES to R.string.shard_nav_houses,
)
Card(Modifier.fillMaxWidth()) {
private fun BoardsCard(features: ShardFeatures?, onOpenBoard: (ShardBoard) -> Unit) {
val boards = visibleBoards(features).map { board ->
board to when (board) {
ShardBoard.CHAMPS -> R.string.shard_nav_champs
ShardBoard.GUILDS -> R.string.shard_nav_guilds
ShardBoard.GOVERNORS -> R.string.shard_nav_governors
ShardBoard.HOUSES -> R.string.shard_nav_houses
}
}
if (boards.isEmpty()) return
ShardCard(Modifier.fillMaxWidth()) {
Column {
boards.forEachIndexed { index, (board, labelRes) ->
Text(

View File

@@ -10,9 +10,11 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.OnlineStaffDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.data.repository.ShardFeaturesRepository
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.toUiState
import com.runicgateway.app.ui.toShardUiState
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -39,11 +41,18 @@ data class ShardHub(
@HiltViewModel
class ShardViewModel @Inject constructor(
private val repository: ShardRepository,
shardFeaturesRepository: ShardFeaturesRepository,
) : ViewModel() {
private val _state = MutableStateFlow<UiState<ShardHub>>(UiState.Loading)
val state: StateFlow<UiState<ShardHub>> = _state.asStateFlow()
/**
* Which boards to offer (M11). Read-only here — the app shell refreshes this on
* every session change, and the hub only filters its tiles with it.
*/
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
private val _feed = MutableStateFlow<List<FeedLine>>(emptyList())
val feed: StateFlow<List<FeedLine>> = _feed.asStateFlow()
@@ -69,8 +78,8 @@ class ShardViewModel @Inject constructor(
seedFeed()
}
// Both error variants are ApiResult<Nothing>, so their UiState is Nothing-typed.
is ApiResult.HttpError -> _state.value = status.toUiState()
is ApiResult.NetworkError -> _state.value = status.toUiState()
is ApiResult.HttpError -> _state.value = status.toShardUiState()
is ApiResult.NetworkError -> _state.value = status.toShardUiState()
}
}
}

View File

@@ -10,6 +10,11 @@ import androidx.compose.ui.graphics.Color
* without the leading `#`) into a Compose [Color]. Returns null for anything
* unparseable so the theme falls back to its default scheme (PLAN.md §3, §5).
* Pure logic — covered by JVM unit tests.
*
* Also the parser for every color token in the shard's resolved theme map
* ([ShardPalette.resolve], M12): the server validates those as `#RGB` or
* `#RRGGBB` on write, and a null here is what makes a token that slipped
* through anyway cost only itself.
*/
fun parseBrandColor(hex: String?): Color? {
if (hex.isNullOrBlank()) return null

View File

@@ -6,35 +6,117 @@ package com.runicgateway.app.ui.theme
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontVariation
import androidx.compose.ui.text.font.FontWeight
import com.runicgateway.app.R
/**
* Type families for the M5 shard-website design pass (docs/android/PLAN.md §M5).
* Every type family the app can draw with — the eight the admin's Appearance page
* can select between (THEMING_AND_NAV.md §5.3) plus the two system stacks its
* "shipped default" options resolve to.
*
* - [Cinzel] — the engraved serif display face used for headings, screen titles,
* and the top-bar title. Shipped as a single weight-axis **variable** font
* (`res/font/cinzel_variable.ttf`, SIL OFL — see `app/licenses/Cinzel-OFL.txt`);
* the 500/600/700 instances the design uses are pinned via [FontVariation]
* (supported on API 26+, and our minSdk is 29).
* - [AppSerif] — the parchment body face. Android's platform serif is Noto Serif,
* which reads as the design's Georgia body copy without bundling another binary.
* - [AppSans] — the label/meta/button face (the design's "Helvetica Neue" runs).
* All eight webfonts are **bundled**, not downloadable: the Play Store font
* provider is the only downloadable-font source Compose ships with, so a
* de-Googled device would silently fall back and every text style would gain an
* async loading state. The binaries are taken verbatim from `google/fonts`, which
* is how [Cinzel] arrived in M5; each carries its SIL OFL licence under
* `app/licenses/`, **never** under `res/font/` (aapt rejects a `.txt` there).
*
* A family reaches a text style through [ShardTypeface], which is what maps a
* shard's `--display` / `--serif` / `--sans` stacks onto these. The three
* declared *shipped* roles are [Cinzel] for the engraved display/headline/title
* block, [AppSerif] for parchment body copy, and [AppSans] for the letter-spaced
* label/meta/button block.
*
* ## Weights
*
* The type scale asks for four: 400 (body), 500 and 700 (labels), 600 (display).
* Every family here must supply all four, because a family is **not** confined to
* the role its dropdown lives in — the `modern` preset puts Work Sans in the
* display slot and the `fantasy` preset puts EB Garamond in the sans slot, both
* bypassing the server's per-role option list (see [ShardTypeface]). The variable
* families pin the four instances through [FontVariation] (API 26+; minSdk is 29).
*
* Two families are exceptions, both upstream facts rather than choices:
* - **[IMFellEnglish] has a single weight.** Its one 400 face answers all four
* requests and Android synthesises the bold. The website's dropdown labels it
* "(no bold weight)" for the same reason.
* - **[Cinzel] is left at the 500/600/700 it shipped with in M5.** It is the only
* family the server offers in the display role alone, so nothing can ask it for
* 400; adding an instance would have edited M5's type for no reachable case.
*
* ## Italics
*
* Four families carry a true italic — the same four `client/index.html` requests
* one for. The rest are upright-only and Compose skews them, which is what the
* app already did for every family before this milestone and what the website
* does for its own upright-only faces. The app draws italic in two places.
*/
@OptIn(ExperimentalTextApi::class)
private fun cinzel(weight: FontWeight) =
private fun variable(resId: Int, weight: FontWeight, style: FontStyle = FontStyle.Normal) =
Font(
R.font.cinzel_variable,
resId,
weight = weight,
style = style,
variationSettings = FontVariation.Settings(FontVariation.weight(weight.weight)),
)
// The four weights the type scale asks for, in the order Compose prefers to match.
private val ScaleWeights = listOf(
FontWeight.Normal, // 400 — body
FontWeight.Medium, // 500 — labelMedium / labelSmall
FontWeight.SemiBold, // 600 — display / headline / title
FontWeight.Bold, // 700 — labelLarge
)
/** A variable family pinned at the four scale weights, upright only. */
private fun variableFamily(resId: Int) =
FontFamily(ScaleWeights.map { variable(resId, it) })
/** A variable family pinned at the four scale weights, upright and italic. */
private fun variableFamily(uprightResId: Int, italicResId: Int) =
FontFamily(
ScaleWeights.map { variable(uprightResId, it) } +
ScaleWeights.map { variable(italicResId, it, FontStyle.Italic) },
)
private fun cinzel(weight: FontWeight) = variable(R.font.cinzel_variable, weight)
val Cinzel = FontFamily(
cinzel(FontWeight.Medium), // 500
cinzel(FontWeight.SemiBold), // 600
cinzel(FontWeight.Bold), // 700
)
val EBGaramond = variableFamily(R.font.eb_garamond_variable, R.font.eb_garamond_italic)
val Merriweather = variableFamily(R.font.merriweather_variable, R.font.merriweather_italic)
val PlayfairDisplay =
variableFamily(R.font.playfair_display_variable, R.font.playfair_display_italic)
val Inter = variableFamily(R.font.inter_variable)
val WorkSans = variableFamily(R.font.work_sans_variable)
val SourceSans3 = variableFamily(R.font.source_sans_3_variable)
/**
* IM Fell English, whose upstream release is a single 400 face per style — there
* is no weight axis and no bold cut to pin. Declared once per style so a request
* at 500/600/700 lands on it rather than falling out of the family.
*/
val IMFellEnglish = FontFamily(
Font(R.font.im_fell_english_regular, weight = FontWeight.Normal),
Font(R.font.im_fell_english_italic, weight = FontWeight.Normal, style = FontStyle.Italic),
)
/**
* The platform serif (Noto Serif), which is what the website's
* `Georgia, "Times New Roman", serif` stack resolves to on Android — and the
* app's shipped body face since M5.
*/
val AppSerif = FontFamily.Serif
/**
* The platform sans (Roboto), which the website's
* `"Helvetica Neue", Arial, sans-serif` stack resolves to on Android — and the
* app's shipped label face since M5.
*/
val AppSans = FontFamily.SansSerif

View File

@@ -0,0 +1,126 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
/**
* The shard's resolved color palette — the fifteen themable tokens of
* `GET /public/settings`' `theme` map, parsed into Compose colors
* (THEMING_AND_NAV.md §5.1).
*
* **The default value of every field is the shipped constant from
* [ui/theme/Color.kt], and that is not an approximation.** The app's M5 palette
* *is* the website's `runic-gateway` preset, value for value, because both were
* drawn from the same `theme.css`. So [Shipped] renders exactly as the app did
* before this milestone, and an instance with no `theme_visual` row resolves
* back to it token by token (§2, AC-1).
*
* The palette has two consumers and one resolution: ten of the fifteen tokens
* have a Material role and are fed into the [androidx.compose.material3.ColorScheme]
* by [shardColorScheme]; the other five have none, and reach the screens that
* need them through [LocalShardPalette].
*/
@Immutable
data class ShardPalette(
/** `--bg-deep` — the page behind everything. */
val page: Color = ShardPage,
/** `--bg` — the screen background. */
val surface: Color = ShardSurface,
/** `--panel-flat` — top bar, inputs, drawer, list tracks. */
val elevated: Color = ShardElevated,
/** `--panel-a` — feature-card gradient, top. No Material role. */
val cardTop: Color = ShardCardTop,
/** `--panel-b` — feature-card gradient, bottom. No Material role. */
val cardBottom: Color = ShardCardBottom,
/** `--line` — borders and input outlines. */
val outline: Color = ShardOutline,
/** `--line-soft` — hairline row dividers. */
val divider: Color = ShardDivider,
/** `--ink` — the brightest headings. No Material role. */
val heading: Color = ShardHeading,
/** `--head` — heading on a surface. No Material role. */
val headingDim: Color = ShardHeadingDim,
/** `--text` — body copy. */
val body: Color = ShardBody,
/** `--muted` — secondary text. */
val muted: Color = ShardMuted,
/** `--dim` — meta and faint labels. No Material role. */
val faint: Color = ShardFaint,
/** `--accent` — links and secondary highlights. */
val accent: Color = ShardAccent,
/** `--accent-bright` — the filled CTA surface. */
val cta: Color = ShardCta,
/** `--blue` — the neutral/info pill background. */
val pillBg: Color = ShardPillBg,
) {
/**
* Text drawn on the [cta] fill. **Derived, never themed** — it tracks
* `--bg-deep`, exactly as the server refuses to freeze `--panel-grad` as a
* literal (§5.1). A value expressed in terms of another token must follow
* it, or a future light preset inherits a dark one and looks broken.
*/
val onCta: Color get() = page
/**
* The neutral/info pill's foreground. Also derived: `ShardPillFg` and
* `ShardCta` are the same `--accent-bright` value, so the pill's text
* follows the CTA fill rather than being a sixteenth token the contract
* does not have.
*/
val pillFg: Color get() = cta
companion object {
/** The shipped app: the M5 palette, i.e. the `runic-gateway` preset. */
val Shipped = ShardPalette()
/**
* Resolve a `theme` token map into a palette, **field by field** (§2).
* A token that is missing, blank or unparseable falls back to its
* shipped value on its own; a bad `--accent` must never discard a good
* `--bg` beside it (AC-2).
*
* [brandAccent] is the pre-feature branding path and must keep working:
* an instance with a `BRAND_ACCENT_COLOR` but no `theme_visual` row
* still tints its links and highlights. It seeds `--accent` only — the
* server resolves `brand.accent` as `theme['--accent'] || env`, so the
* token always wins where both exist.
*/
fun resolve(theme: Map<String, String>, brandAccent: Color? = null): ShardPalette {
if (theme.isEmpty() && brandAccent == null) return Shipped
fun token(name: String, shipped: Color): Color =
parseBrandColor(theme[name]) ?: shipped
return ShardPalette(
page = token("--bg-deep", ShardPage),
surface = token("--bg", ShardSurface),
elevated = token("--panel-flat", ShardElevated),
cardTop = token("--panel-a", ShardCardTop),
cardBottom = token("--panel-b", ShardCardBottom),
outline = token("--line", ShardOutline),
divider = token("--line-soft", ShardDivider),
heading = token("--ink", ShardHeading),
headingDim = token("--head", ShardHeadingDim),
body = token("--text", ShardBody),
muted = token("--muted", ShardMuted),
faint = token("--dim", ShardFaint),
accent = token("--accent", brandAccent ?: ShardAccent),
cta = token("--accent-bright", ShardCta),
pillBg = token("--blue", ShardPillBg),
)
}
}
}
/**
* The live palette, for the five tokens with no Material role and for the
* components that draw the card gradient. Everything that *can* go through
* `MaterialTheme.colorScheme` still should — this is the escape hatch, not the
* front door.
*
* Defaulted to [ShardPalette.Shipped] so previews and any composable outside
* [RunicGatewayTheme] still draw the shipped palette rather than crashing.
*/
val LocalShardPalette = staticCompositionLocalOf { ShardPalette.Shipped }

View File

@@ -0,0 +1,179 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Shapes
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlin.math.abs
import kotlin.math.roundToInt
/**
* The shard's resolved corner radii and card depth — the `structure` half of the
* admin's Appearance page (THEMING_AND_NAV.md §5.2, §5.4), the counterpart to
* [ShardPalette].
*
* **Radii are applied as a ratio, never as a literal.** The app's [Shapes] came
* from the M5 mockup and the website's from `theme.css`; the two scales genuinely
* differ (`--radius-card` 10px against `medium` 12dp). Copying the web value in
* would restyle an untouched app the day this milestone shipped, so each field is
* scaled by `resolved ÷ runic-gateway baseline` instead. A shard on the shipped
* theme, or one that explicitly picks `runic-gateway`, gives ratio 1.0 on every
* field and is a provable no-op (§2, AC-1).
*
* Card depth is the one thing here that is **not** a no-op — see [ShippedCardElevation].
*/
@Immutable
data class ShardStructure(
/** The Material shape scale, ratio-scaled off the app's own shipped dp values. */
val shapes: Shapes = ShippedShapes,
/**
* `--radius-pill`. Not part of [shapes]: the app draws its chips with
* [CircleShape], which is a percentage and so has no dp for a ratio to scale.
* Resolved as a literal instead — the only rule available — see [pillShape].
*/
val pill: Shape = CircleShape,
/** `--shadow-card`, mapped onto Material elevation (§5.4). */
val cardElevation: Dp = ShippedCardElevation,
) {
companion object {
/** The shipped app: the M5 shape scale and the `runic-gateway` card depth. */
val Shipped = ShardStructure()
/**
* Resolve a `theme` token map into a structure, **field by field** (§2):
* a `--radius-panel` the server never validated must not cost the
* `--radius-card` beside it, exactly as in [ShardPalette.resolve].
*/
fun resolve(theme: Map<String, String>): ShardStructure {
if (theme.isEmpty()) return Shipped
val input = ratio(theme["--radius-input"], BaseInputPx)
val card = ratio(theme["--radius-card"], BaseCardPx)
val panel = ratio(theme["--radius-panel"], BasePanelPx)
return ShardStructure(
shapes = Shapes(
extraSmall = corner(ShippedExtraSmallDp, input),
small = corner(ShippedSmallDp, input),
medium = corner(ShippedMediumDp, card),
// extraLarge has no web counterpart and follows the panel
// ratio, since it is the panel family.
large = corner(ShippedLargeDp, panel),
extraLarge = corner(ShippedExtraLargeDp, panel),
),
pill = pillShape(theme["--radius-pill"]),
cardElevation = elevation(theme["--shadow-card"]),
)
}
}
}
/**
* The live structure, for the two things Material's theme cannot carry: the pill
* shape, and a card elevation ([androidx.compose.material3.Card] takes its
* elevation as a default argument, not from a composition local). The shape
* scale itself reaches screens through `MaterialTheme.shapes` and needs nothing
* here.
*/
val LocalShardStructure = staticCompositionLocalOf { ShardStructure.Shipped }
// ── the shipped scale ──────────────────────────────────────────────────────
//
// The app's own dp values, which the ratios scale. Kept here rather than in
// Theme.kt so the resolution and the thing it resolves back to sit together.
private const val ShippedExtraSmallDp = 8
private const val ShippedSmallDp = 8
private const val ShippedMediumDp = 12
private const val ShippedLargeDp = 16
private const val ShippedExtraLargeDp = 24
/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
internal val ShippedShapes = Shapes(
extraSmall = RoundedCornerShape(ShippedExtraSmallDp.dp),
small = RoundedCornerShape(ShippedSmallDp.dp),
medium = RoundedCornerShape(ShippedMediumDp.dp),
large = RoundedCornerShape(ShippedLargeDp.dp),
extraLarge = RoundedCornerShape(ShippedExtraLargeDp.dp),
)
/**
* The depth an unthemed instance draws its cards at.
*
* **This is the one field of this milestone that is deliberately not a no-op.**
* The app has been flat since M5 — Material's filled `Card` is `Level0` and
* `FeatureCard` never had the shadow its own docs claimed — while the
* `runic-gateway` preset's `--shadow-card` is the "Default" option. §5.4 is
* applied as written rather than rebased on the app's flat baseline, so every
* card gains this depth and the admin's four-step control reads the same on the
* phone as on the web. Approved by the org lead as an amendment to §2.
*/
private val ShippedCardElevation = 4.dp
// ── the runic-gateway baselines ───────────────────────────────────────────
//
// The preset the app's own scale corresponds to (server/src/config/themePresets.js).
// A resolved value is meaningful only against these: the ratio, not the number,
// is what crosses from the web scale to the app's.
private const val BaseInputPx = 8f
private const val BaseCardPx = 10f
private const val BasePanelPx = 12f
private const val BasePillPx = 999f
/**
* Below half the pill baseline the chip stops reading as a pill and becomes a
* rounded rectangle, so an admin who squares the site off squares off the app's
* chips too. Fantasy's 4px and Modern's 8px both land here; `runic-gateway`'s
* 999px does not.
*/
private const val PillCircleFloorPx = BasePillPx / 2f
// A radius as the server writes it: an integer count of px, 0..999, always with
// the unit (`isRadius` in utils/themeResolve.js). Anything else is not a value
// this app can scale, and falls back to the shipped dp on its own.
private val RadiusPx = Regex("""^\s*(\d{1,3})px\s*$""")
// The blur of a CSS box-shadow: `0 14px 34px rgba(...)`. The x offset carries no
// unit, so the blur is the second px length.
private val ShadowLengthPx = Regex("""(\d+(?:\.\d+)?)px""")
/**
* `--shadow-card` mapped to elevation, by **nearest blur** rather than by exact
* string. §5.4 specified a string match against the server's `SHADOW_OPTIONS`,
* but the Fantasy preset publishes `0 16px 38px rgba(0, 0, 0, 0.45)` — a value
* `SHADOW_OPTIONS` does not contain, because a preset's own tokens never pass
* through that dropdown. An exact match would have missed the one preset whose
* point is a heavier shadow. Matching the blur puts any future preset on the
* nearest step instead of silently on the default.
*/
private val ShadowSteps = listOf(20f to 2.dp, 34f to 4.dp, 44f to 8.dp)
private fun parseRadiusPx(raw: String?): Float? =
raw?.let { RadiusPx.find(it) }?.groupValues?.get(1)?.toFloatOrNull()
private fun ratio(raw: String?, baselinePx: Float): Float =
parseRadiusPx(raw)?.let { it / baselinePx } ?: 1f
/** Scale one shipped dp by its ratio, rounded to whole dp and clamped at 0. */
private fun corner(shippedDp: Int, ratio: Float) =
RoundedCornerShape((shippedDp * ratio).roundToInt().coerceAtLeast(0).dp)
private fun pillShape(raw: String?): Shape {
val px = parseRadiusPx(raw) ?: return CircleShape
return if (px >= PillCircleFloorPx) CircleShape else RoundedCornerShape(px.roundToInt().dp)
}
private fun elevation(raw: String?): Dp {
val value = raw?.trim() ?: return ShippedCardElevation
if (value.equals("none", ignoreCase = true)) return 0.dp
val blur = ShadowLengthPx.findAll(value).drop(1).firstOrNull()
?.groupValues?.get(1)?.toFloatOrNull()
?: return ShippedCardElevation
return ShadowSteps.minByOrNull { abs(it.first - blur) }?.second ?: ShippedCardElevation
}

View File

@@ -0,0 +1,95 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.text.font.FontFamily
/**
* The shard's resolved type families — the `fonts` third of the admin's
* Appearance page (THEMING_AND_NAV.md §5.3), alongside [ShardPalette] and
* [ShardStructure].
*
* The three roles map onto the M5 type scale's three groups verbatim: `--display`
* carries the Cinzel display/headline/title block, `--serif` the body block, and
* `--sans` the label/meta/button block. Sizes, weights and tracking do not move —
* only the family, which is why [shardTypography] is a one-field substitution and
* an unthemed shard is a provable no-op (§2, AC-1).
*
* Resolution is pure, so the acceptance tests need no Compose rule.
*/
@Immutable
data class ShardTypeface(
/** `--display`. */
val display: FontFamily = Cinzel,
/** `--serif`. */
val serif: FontFamily = AppSerif,
/** `--sans`. */
val sans: FontFamily = AppSans,
) {
companion object {
/** The shipped app: the three M5 families. */
val Shipped = ShardTypeface()
/**
* Resolve a `theme` token map into three families, **field by field** (§2):
* an unreadable `--sans` must not cost the `--serif` beside it, exactly as
* in [ShardPalette.resolve] and [ShardStructure.resolve].
*/
fun resolve(theme: Map<String, String>): ShardTypeface {
if (theme.isEmpty()) return Shipped
return ShardTypeface(
display = family(theme["--display"]) ?: Shipped.display,
serif = family(theme["--serif"]) ?: Shipped.serif,
sans = family(theme["--sans"]) ?: Shipped.sans,
)
}
}
}
/**
* Every family name the server can publish, keyed by the lowercased first family
* of the stack.
*
* **This map is deliberately global rather than per-role**, and that is not a
* simplification. The server validates an *admin-entered* font against
* `FONT_OPTIONS[role]`, but a preset's tokens are copied verbatim by
* `resolveThemeTokens` and never pass through that list — `modern` publishes
* `--display: 'Work Sans', Arial, sans-serif`, which the display dropdown does
* not offer, and `fantasy` publishes `--sans: 'EB Garamond', Georgia, serif`,
* which the sans dropdown does not either. A per-role lookup would have missed
* the display face of one preset and the label face of the other. It is the same
* trap phase 2 hit with `--shadow-card`, in a different token group.
*
* The two system entries are the "shipped default" options: neither pulls in a
* webfont on the web, and on Android both resolve to the platform family the app
* has drawn with since M5.
*/
private val FamiliesByFirstName: Map<String, FontFamily> = mapOf(
"cinzel" to Cinzel,
"eb garamond" to EBGaramond,
"merriweather" to Merriweather,
"playfair display" to PlayfairDisplay,
"im fell english" to IMFellEnglish,
"inter" to Inter,
"work sans" to WorkSans,
"source sans 3" to SourceSans3,
"georgia" to AppSerif,
"helvetica neue" to AppSans,
)
/**
* Resolve a CSS font stack to a family by **its first name**, which is how the
* value is constructed server-side and the only part of it that carries the
* admin's choice — everything after the first comma is the web's fallback chain,
* which Android has no use for.
*
* Returns `null` for a stack this app cannot draw, so the caller falls back to
* the role's shipped family rather than to some other role's.
*/
private fun family(stack: String?): FontFamily? {
val first = stack?.substringBefore(',')?.trim()?.trim('\'', '"')?.trim()
if (first.isNullOrEmpty()) return null
return FamiliesByFirstName[first.lowercase()]
}

View File

@@ -3,78 +3,100 @@
*/
package com.runicgateway.app.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import com.runicgateway.app.data.appearance.SiteAppearance
/**
* The shard-website color scheme (M5 design pass). The app is **dark-only** — the
* design is a single deep blue-black theme, so there is no light variant and the
* system light/dark setting is intentionally ignored. Material roles are mapped
* onto the palette in [ui/theme/Color.kt] so the ~20 token-based screens take on
* the theme without per-screen color work.
* Maps a resolved [ShardPalette] onto the Material roles (THEMING_AND_NAV.md
* §5.1). The app is **dark-only** — the design is a single deep blue-black
* theme, so there is no light variant and the system light/dark setting is
* intentionally ignored; every v1 preset on the website is dark too.
*
* Ten of the palette's fifteen tokens land here, which is why the ~20
* token-based screens take on a shard's theme with no per-screen color work.
* Pure, so the no-op proof (AC-1) can assert on it directly.
*/
private val ShardColorScheme = darkColorScheme(
primary = ShardCta, // filled CTA buttons
onPrimary = ShardOnCta,
secondary = ShardAccent, // links / secondary highlights
onSecondary = ShardOnCta,
tertiary = ShardAccent,
onTertiary = ShardOnCta,
background = ShardPage,
onBackground = ShardBody,
surface = ShardSurface,
onSurface = ShardBody,
surfaceVariant = ShardElevated,
onSurfaceVariant = ShardMuted,
surfaceContainer = ShardElevated,
surfaceContainerHigh = ShardElevated,
surfaceContainerLow = ShardSurface,
outline = ShardOutline,
outlineVariant = ShardDivider,
secondaryContainer = ShardPillBg, // neutral chips / selected drawer item
onSecondaryContainer = ShardPillFg,
internal fun shardColorScheme(palette: ShardPalette): ColorScheme = darkColorScheme(
primary = palette.cta, // filled CTA buttons
onPrimary = palette.onCta,
secondary = palette.accent, // links / secondary highlights
onSecondary = palette.onCta,
tertiary = palette.accent,
onTertiary = palette.onCta,
background = palette.page,
onBackground = palette.body,
surface = palette.surface,
onSurface = palette.body,
surfaceVariant = palette.elevated,
onSurfaceVariant = palette.muted,
surfaceContainer = palette.elevated,
surfaceContainerHigh = palette.elevated,
// Material's filled Card takes its container from surfaceContainerHighest —
// FilledCardTokens.ContainerColor, checked in the 1.3.0 artifact's bytecode.
// Leaving it unmapped is what made every ShardCard draw in darkColorScheme()'s
// default grey instead of --panel-flat, on themed AND untouched instances alike
// (found on device in phase 8's AC-5 walk; see "Phase 8 as landed").
surfaceContainerHighest = palette.elevated,
surfaceContainerLow = palette.surface,
surfaceContainerLowest = palette.surface,
outline = palette.outline,
outlineVariant = palette.divider,
secondaryContainer = palette.pillBg, // neutral chips / selected drawer item
onSecondaryContainer = palette.pillFg,
// Semantic, never themed — mirrors the server's FIXED_TOKENS (§4).
error = ShardDanger,
onError = ShardOnCta,
onError = palette.onCta,
errorContainer = ShardDangerBg,
onErrorContainer = ShardDanger,
)
/** 8dp inputs/chips, 12dp cards, 16dp large surfaces — matching the mockup radii. */
private val ShardShapes = Shapes(
extraSmall = RoundedCornerShape(8.dp),
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp),
extraLarge = RoundedCornerShape(24.dp),
)
/**
* App theme. The color scheme is the fixed shard-website dark palette; when a shard
* publishes a brand accent (PLAN.md §3), it seeds the [MaterialTheme]'s primary and
* secondary roles so buttons and highlights carry that shard's color while the rest
* of the deep blue-black system stays intact. With no accent, the slate default is
* used.
* App theme, themed by the shard (M12). [appearance] carries the resolved token
* map the admin's Appearance page publishes; it is applied field by field over
* the shipped palette and shape scale, so [SiteAppearance.NONE] — no settings
* rows, a backend that predates the feature, or a settings call that failed —
* renders as the app did before this milestone (§2), the one exception being the
* card depth [ShardStructure] documents.
*
* The palette reaches screens two ways: through [MaterialTheme]'s color scheme
* for the ten tokens with a Material role, and through [LocalShardPalette] for
* the five without one. The radii split the same way — [MaterialTheme]'s shape
* scale for everything Material draws, [LocalShardStructure] for the pill and
* the card depth, which it cannot carry. The type families need no split and so
* no composition local: every text style in the app comes from
* [MaterialTheme.typography], and the two that override anything override the
* style rather than the family.
*/
@Composable
fun RunicGatewayTheme(
accent: Color? = null,
appearance: SiteAppearance = SiteAppearance.NONE,
content: @Composable () -> Unit,
) {
val colorScheme = if (accent != null) {
ShardColorScheme.copy(primary = accent, secondary = accent, tertiary = accent)
} else {
ShardColorScheme
val palette = remember(appearance) {
ShardPalette.resolve(
theme = appearance.theme,
brandAccent = parseBrandColor(appearance.brand?.accent),
)
}
val colorScheme = remember(palette) { shardColorScheme(palette) }
val structure = remember(appearance) { ShardStructure.resolve(appearance.theme) }
val typeface = remember(appearance) { ShardTypeface.resolve(appearance.theme) }
val typography = remember(typeface) { shardTypography(typeface) }
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
shapes = ShardShapes,
content = content,
)
CompositionLocalProvider(
LocalShardPalette provides palette,
LocalShardStructure provides structure,
) {
MaterialTheme(
colorScheme = colorScheme,
typography = typography,
shapes = structure.shapes,
content = content,
)
}
}

View File

@@ -9,73 +9,82 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/**
* The M5 type scale. Three families carry the design (see [ui/theme/Font.kt]):
* [Cinzel] for the engraved display/headline/title roles, [AppSerif] (Noto Serif)
* for parchment body copy, and [AppSans] for the letter-spaced label/meta/button
* roles. Sizes and tracking mirror the "Runic Gateway Screens" mockup.
* The M5 type scale, drawn in whichever three families the shard resolved to
* (THEMING_AND_NAV.md §5.3).
*
* Only the family moves. Every size, weight, line height and tracking below is
* the M5 value from the "Runic Gateway Screens" mockup, so `ShardTypeface.Shipped`
* — an unthemed instance, a backend that predates the feature, a settings call
* that failed — reproduces the pre-M12 scale exactly. [androidx.compose.material3.Typography]
* implements `equals`, so AC-1 asserts that in one comparison.
*
* The three groups map onto the three roles verbatim: [ShardTypeface.display]
* carries the engraved display/headline/title block, [ShardTypeface.serif] the
* parchment body copy, and [ShardTypeface.sans] the letter-spaced
* label/meta/button roles.
*/
val Typography = Typography(
// Display / headline / title — Cinzel engraved serif
internal fun shardTypography(faces: ShardTypeface) = Typography(
// Display / headline / title — the engraved serif role
displayLarge = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 40.sp, lineHeight = 46.sp, letterSpacing = 0.4.sp,
),
displayMedium = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 32.sp, lineHeight = 40.sp, letterSpacing = 0.3.sp,
),
displaySmall = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 28.sp, lineHeight = 36.sp, letterSpacing = 0.2.sp,
),
headlineLarge = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 26.sp, lineHeight = 34.sp, letterSpacing = 0.2.sp,
),
headlineMedium = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 24.sp, lineHeight = 32.sp, letterSpacing = 0.2.sp,
),
headlineSmall = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 22.sp, lineHeight = 28.sp, letterSpacing = 0.2.sp,
),
titleLarge = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = 0.2.sp,
),
titleMedium = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 17.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
),
titleSmall = TextStyle(
fontFamily = Cinzel, fontWeight = FontWeight.SemiBold,
fontFamily = faces.display, fontWeight = FontWeight.SemiBold,
fontSize = 15.sp, lineHeight = 22.sp, letterSpacing = 0.1.sp,
),
// Body — parchment serif
// Body — the parchment serif role
bodyLarge = TextStyle(
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
fontSize = 16.sp, lineHeight = 26.sp, letterSpacing = 0.15.sp,
),
bodyMedium = TextStyle(
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
fontSize = 15.sp, lineHeight = 24.sp, letterSpacing = 0.15.sp,
),
bodySmall = TextStyle(
fontFamily = AppSerif, fontWeight = FontWeight.Normal,
fontFamily = faces.serif, fontWeight = FontWeight.Normal,
fontSize = 13.sp, lineHeight = 20.sp, letterSpacing = 0.2.sp,
),
// Labels / meta / buttons — sans, letter-spaced
// Labels / meta / buttons — the sans role, letter-spaced
labelLarge = TextStyle(
fontFamily = AppSans, fontWeight = FontWeight.Bold,
fontFamily = faces.sans, fontWeight = FontWeight.Bold,
fontSize = 14.sp, lineHeight = 18.sp, letterSpacing = 0.45.sp,
),
labelMedium = TextStyle(
fontFamily = AppSans, fontWeight = FontWeight.Medium,
fontFamily = faces.sans, fontWeight = FontWeight.Medium,
fontSize = 12.sp, lineHeight = 16.sp, letterSpacing = 0.4.sp,
),
labelSmall = TextStyle(
fontFamily = AppSans, fontWeight = FontWeight.Medium,
fontFamily = faces.sans, fontWeight = FontWeight.Medium,
fontSize = 11.sp, lineHeight = 15.sp, letterSpacing = 0.5.sp,
),
)

View File

@@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -30,6 +29,7 @@ import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.components.EmptyView
import com.runicgateway.app.ui.components.ErrorView
import com.runicgateway.app.ui.components.LoadingView
import com.runicgateway.app.ui.components.ShardCard
/** Wiki index: search field + page list (PLAN.md §6.1). */
@Composable
@@ -72,7 +72,7 @@ fun WikiScreen(
private fun WikiList(pages: List<WikiSummaryDto>, onOpenPage: (String) -> Unit) {
LazyColumn(modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp)) {
items(pages, key = { it.id }) { page ->
Card(
ShardCard(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -16,6 +16,7 @@
<string name="error_not_found">This content couldn\'t be found.</string>
<string name="error_rate_limited">Too many requests. Please try again in a moment.</string>
<string name="error_shard_offline">The shard is offline right now.</string>
<string name="error_feature_unavailable">This shard doesn\'t publish this here.</string>
<string name="error_server">Something went wrong on the server. Please try again.</string>
<!-- ── First-run connect (§3) ──────────────────────────────────────── -->
@@ -36,10 +37,16 @@
<!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
<string name="nav_open_menu">Open navigation menu</string>
<!-- On an admin-added link the app has no screen for; it opens in a browser (§6.3). -->
<string name="nav_opens_in_browser">Opens in your browser</string>
<string name="menu_home">Home</string>
<string name="menu_news">News</string>
<string name="menu_wiki">Wiki</string>
<string name="menu_shard">Shard</string>
<string name="menu_rules">Rules</string>
<string name="menu_atlas">Atlas</string>
<string name="menu_leaderboards">Leaderboards</string>
<string name="menu_market">Market</string>
<string name="menu_about">About</string>
<string name="menu_contact">Contact</string>
<string name="menu_account">My account</string>
@@ -290,6 +297,11 @@
<string name="player_char_pois">Poison</string>
<string name="player_char_energy">Energy</string>
<string name="player_char_skills">Skills</string>
<string name="player_char_points">Loyalty &amp; Points</string>
<!-- A point system's name followed by the character's rank on that board, e.g. "Queens Loyalty · #3". -->
<string name="player_char_points_ranked">%1$s · #%2$d</string>
<!-- A score against its cap. Only shown for capped systems; an uncapped score shows the number alone. -->
<string name="player_char_points_of">%1$d / %2$d</string>
<string name="player_char_equipment">Equipment</string>
<string name="player_char_item">Item</string>
<string name="player_char_item_id">id %1$d</string>
@@ -376,6 +388,85 @@
<string name="guilds_leader">Led by %1$s</string>
<string name="guilds_alliance">Alliance: %1$s</string>
<!-- ── Rules / ruleset (Protocol 3.0 §5, M11) ──────────────────────── -->
<!-- A successful read of a shard that has never published its ruleset — NOT the same
as the feature being switched off, which renders as an error state. -->
<string name="rules_unpublished">This shard hasn\'t published its ruleset yet.</string>
<string name="rules_section_shard">Shard</string>
<string name="rules_section_systems">Systems</string>
<string name="rules_section_caps">Skill &amp; stat caps</string>
<string name="rules_section_accounts">Accounts &amp; housing</string>
<string name="rules_section_vendors">Vendors</string>
<string name="rules_section_schedule">Saves &amp; restarts</string>
<string name="rules_name">Name</string>
<string name="rules_expansion">Expansion</string>
<string name="rules_connect">Connect</string>
<string name="rules_skill_cap">Individual skill cap</string>
<string name="rules_total_skill_cap">Total skill cap</string>
<string name="rules_stat_cap">Total stat cap</string>
<string name="rules_str_cap">Strength cap</string>
<string name="rules_dex_cap">Dexterity cap</string>
<string name="rules_int_cap">Intelligence cap</string>
<string name="rules_char_slots">Character slots</string>
<string name="rules_per_ip">Accounts per IP</string>
<string name="rules_house_limit">Houses per account</string>
<string name="rules_restock_delay">Vendor restock delay</string>
<string name="rules_max_sell">Max sell quantity</string>
<string name="rules_autosave">Auto-save every</string>
<string name="rules_autorestart">Auto-restart at</string>
<string name="rules_minutes">%1$d min</string>
<!-- ── Leaderboards (Protocol 3.0 §7, M11) ─────────────────────────── -->
<string name="leaderboards_empty">This shard isn\'t publishing any leaderboards yet.</string>
<string name="leaderboards_board_empty">Nobody has scored here yet.</string>
<!-- Where a score would sit on the placeholder row of an unscored board. An em
dash, not "0" — nobody has scored zero, nobody has scored at all. -->
<string name="leaderboards_no_score"></string>
<string name="leaderboards_players">%1$d players</string>
<!-- Only shown for capped systems; most systems on a real shard are uncapped. -->
<string name="leaderboards_cap">Cap: %1$d</string>
<string name="leaderboards_rank_name">#%1$d %2$s</string>
<!-- A shard may publish standings without naming who holds them (the board's one
admin-configurable field). -->
<string name="leaderboards_hidden_name">Someone</string>
<!-- ── Market (Protocol 3.0 §8, M11) ───────────────────────────────── -->
<string name="market_search_label">Search every shop</string>
<string name="market_empty">No listings match that search.</string>
<string name="market_shop_empty">This shop has nothing for sale.</string>
<!-- Required, not decoration: the shard sweeps vendors round-robin, so a price can
legitimately be a full cycle old. -->
<string name="market_staleness">Prices are refreshed in rotation and may be out of date.</string>
<string name="market_price">%1$d gp</string>
<string name="market_unnamed_item">Item %1$d</string>
<string name="market_unnamed_shop">A shop</string>
<string name="market_owner">Kept by %1$s</string>
<!-- A gated location is a real answer: the shop exists, the shard just doesn't say
where it stands. -->
<string name="market_location_hidden">This shard doesn\'t publish shop locations.</string>
<string name="market_truncated">Showing %1$d of %2$d — this shop holds more than the shard publishes.</string>
<!-- ── Spawn atlas (Protocol 3.0 §6, M11) ──────────────────────────── -->
<string name="atlas_search_label">Search creatures</string>
<string name="atlas_empty">No creatures match that search.</string>
<!-- A place can legitimately hold a single spawner, and the aggregate list is full
of them — "1 spawners" on every other row is worth a plural for. -->
<plurals name="atlas_spawner_count">
<item quantity="one">%1$d spawner</item>
<item quantity="other">%1$d spawners</item>
</plurals>
<string name="atlas_total_alive">Up to %1$d alive at once</string>
<string name="atlas_facet_count">%1$s (%2$d)</string>
<!-- The aggregate: "where is it", as opposed to the raw coordinates below it. -->
<string name="atlas_section_places">Where it spawns</string>
<string name="atlas_place_max_alive">up to %1$d at once</string>
<string name="atlas_section_spawners">Spawn points</string>
<string name="atlas_section_also_here">Also spawns here</string>
<string name="atlas_spawners_truncated">More spawn points than shown.</string>
<string name="atlas_max_count">Up to %1$d</string>
<!-- The delay is already in seconds; the server normalizes XmlSpawner's mixed units. -->
<string name="atlas_respawn">Respawn %1$s</string>
<!-- ── Governors (§6.2) ────────────────────────────────────────────── -->
<string name="governors_empty">No governors — this shard may not run the City Loyalty system.</string>
<string name="governor_current">Governed by %1$s</string>

View File

@@ -7,6 +7,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
@@ -99,4 +100,57 @@ class PlayerShardDtoTest {
assertTrue(dto.linked)
assertEquals("whitlocktech", dto.account)
}
// ── Protocol 3.0 additions to char.profile ───────────────────────────
@Test fun charProfileDecodesThePointsBlock() {
// Shaped like a real shard's reply: an uncapped board (maxPoints 0), a
// cliloc-named board (nameString null), and no `rank` unless opted in.
val dto = json.decodeFromString<CharProfileDto>(
"""{"serial":"0x24C","name":"Darrow",
"points":[{"system":"QueensLoyalty","nameString":"Queen's Loyalty",
"points":29500,"maxPoints":30000,"rank":3},
{"system":"VoidPool","nameString":null,"points":180,"maxPoints":0}]}""",
)
assertEquals(2, dto.points.size)
val queens = dto.points[0]
assertEquals("Queen's Loyalty", queens.nameString)
assertEquals(29500L, queens.points)
assertEquals(30000L, queens.cap)
assertEquals(3, queens.rank)
val voidPool = dto.points[1]
assertNull("maxPoints 0 means uncapped, not a zero cap", voidPool.cap)
assertNull("rank is absent unless the shard opts in", voidPool.rank)
assertNull(voidPool.nameString)
}
@Test fun charProfileWithoutAPointsBlockDecodesToEmpty() {
// A shard plugin that predates Protocol 3.0 sends no `points` key at all.
val dto = json.decodeFromString<CharProfileDto>("""{"serial":"0x24C","name":"Darrow"}""")
assertEquals(emptyList<CharPointsDto>(), dto.points)
}
@Test fun equipmentDecodesTheServerResolvedClilocName() {
val dto = json.decodeFromString<CharProfileDto>(
"""{"serial":"0x24C",
"equipment":[{"serial":"0x40","layer":"OneHanded","itemId":5040,"cliloc":1023721,
"clilocName":"hatchet"},
{"serial":"0x41","layer":"Shirt","name":"Bob's lucky shirt",
"clilocName":"fancy shirt"}]}""",
)
assertEquals("hatchet", dto.equipment[0].label)
assertEquals("Bob's lucky shirt", dto.equipment[1].label)
}
@Test fun titlesDecodeTheParallelResolvedArrayIncludingItsNulls() {
// rewardResolved carries a null where the cliloc table had nothing; the array
// must stay positionally aligned with `reward`.
val dto = json.decodeFromString<TitlesDto>(
"""{"selected":1,"reward":["1049565","1049566"],
"rewardResolved":[null,"Knight of Trinsic"]}""",
)
assertEquals(listOf("1049565", "1049566"), dto.reward)
assertEquals(listOf(null, "Knight of Trinsic"), dto.rewardResolved)
}
}

View File

@@ -4,6 +4,8 @@
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
@@ -69,5 +71,32 @@ class PublicDtoTest {
assertFalse(dto.registration.password)
assertEquals("", dto.brand.name)
assertEquals(null, dto.push.ntfyUrl)
// …and one that predates admin theming: both M12 fields are simply absent
// (THEMING_AND_NAV.md §2 — absence means the shipped defaults).
assertEquals(null, dto.theme)
assertEquals(null, dto.navPublic)
}
@Test fun settingsDecodesTheThemeAndNavRows() {
val dto = json.decodeFromString<SettingsDto>(
"""{
"theme":{"--accent":"#c8a45c","--radius-card":"3px"},
"nav_public":"{\"/wiki\":{\"label\":\"Codex\"}}",
"theme_visual":"{\"preset\":\"fantasy\"}"
}""",
)
assertEquals("#c8a45c", (dto.theme as JsonObject)["--accent"]?.jsonPrimitive?.content)
// nav_public stays a raw string here: settings.value is TEXT, so it is
// parsed a second time by SiteAppearance.
assertEquals("""{"/wiki":{"label":"Codex"}}""", dto.navPublic)
}
@Test fun anUnexpectedThemeKindStillDecodesTheRest() {
// `theme` is a raw JsonElement precisely so a value we did not expect
// cannot fail the decode and take brand/push with it.
val dto = json.decodeFromString<SettingsDto>(
"""{"theme":"nonsense","brand":{"name":"UOMysticmoon"}}""",
)
assertEquals("UOMysticmoon", dto.brand.name)
}
}

View File

@@ -0,0 +1,153 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decode tests for the four Protocol 3.0 content DTOs, against payloads captured from a
* **live** server rather than hand-written to match the Kotlin types.
*
* These exist because the fakes in `data/api/fake/` construct DTOs directly, so no test
* in the suite ever fed one real JSON — and `AtlasCreatureDto.places` shipped typed
* `List<String>` while the server sends objects. That decodes to an exception, the
* screen renders "something went wrong on the server", and 336 green tests say nothing.
* Nullable-with-defaults protects against a *missing* field, never a *wrong type*.
*/
class ShardContentDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
// ── Spawn atlas (§6) ─────────────────────────────────────────────────
/** Trimmed from `GET /api/v1/public/atlas/creatures/seaserpent` on a real shard. */
private val seaSerpent = """
{"slug":"seaserpent","name":"SeaSerpent","total":6048,"points":477,
"facets":{"Felucca":237,"Trammel":240},"art":"seaserpent.png",
"places":[{"facet":"Felucca","label":"Wilderness","spawners":91,"maxAlive":1194},
{"facet":"Trammel","label":"Britain","spawners":12,"maxAlive":96}],
"spawners":[{"id":1617,"facet":"Felucca","name":"SeaLife#68","x":1691,"y":1623,
"width":350,"height":350,"range":175,"maxCount":15,"minDelay":300,
"maxDelay":600,"todStart":0,"todEnd":0,"todMode":0,
"region":"Britain","landmark":null,"label":"Britain"}],
"spawnersTruncated":true,
"alsoHere":[{"slug":"waterelemental","name":"WaterElemental","shared":242}]}
""".trimIndent()
@Test fun atlasCreatureDetailDecodesTheRealPayload() {
val creature = json.decodeFromString<AtlasCreatureDto>(seaSerpent)
assertEquals("seaserpent", creature.slug)
assertEquals(6048, creature.total)
assertEquals(477, creature.points)
assertEquals(240, creature.facets["Trammel"])
assertTrue(creature.spawnersTruncated)
assertEquals("seaserpent.png", creature.art)
}
@Test fun atlasPlacesAreObjectsNotStrings() {
// The regression. `places` is the aggregate the screen exists to show, and it
// arrives as {facet,label,spawners,maxAlive} — never as a bare place name.
val places = json.decodeFromString<AtlasCreatureDto>(seaSerpent).places
assertEquals(2, places.size)
assertEquals("Wilderness", places[0].label)
assertEquals("Felucca", places[0].facet)
assertEquals(91, places[0].spawners)
assertEquals(1194, places[0].maxAlive)
}
@Test fun atlasCreatureSurvivesAProjectedOrEmptyPayload() {
// The search route sends no `places`/`spawners`/`art`, and the visibility
// framework can drop any field from any of them.
val lean = json.decodeFromString<AtlasCreatureDto>("""{"slug":"orc"}""")
assertEquals("orc", lean.slug)
assertTrue(lean.places.isEmpty())
assertTrue(lean.spawners.isEmpty())
assertNull(lean.art)
val bare = json.decodeFromString<AtlasCreatureDto>("""{"places":[{}]}""")
assertNull(bare.places[0].label)
assertNull(bare.places[0].spawners)
}
// ── Market (§8) ──────────────────────────────────────────────────────
@Test fun marketListingDecodesWithItsNestedVendorAndLocation() {
// `location` nests on the wire so one visibility rule covers map/x/y/region/house.
val listing = json.decodeFromString<MarketListingDto>(
"""{"serial":"0x40014A57","itemId":3937,"hue":1878,"amount":1,"price":115,
"name":null,"cliloc":1023937,"displayName":"longsword","child":false,
"vendor":{"serial":"0x2CB","shopName":"Seed Shop 225","ownerSerial":"0x201",
"ownerName":"Seed004A",
"location":{"map":"Felucca","x":1562,"y":1604,"z":0,
"region":"Britain","house":"Seed House 4"}}}""",
)
assertEquals("longsword", listing.displayName)
assertEquals(115L, listing.price)
assertEquals("Seed Shop 225", listing.vendor?.shopName)
assertEquals("Felucca", listing.vendor?.location?.map)
}
@Test fun marketListingSurvivesTheFieldsAVisitorMayNotSee() {
// Below the `staff` rung the server omits ownerName/ownerSerial, and below
// `player` the whole nested location. Neither may break the decode.
val projected = json.decodeFromString<MarketListingDto>(
"""{"serial":"0x40014A57","price":115,"displayName":"longsword",
"vendor":{"serial":"0x2CB","shopName":"Seed Shop 225"}}""",
)
assertEquals("Seed Shop 225", projected.vendor?.shopName)
assertNull(projected.vendor?.ownerName)
assertNull(projected.vendor?.location)
}
// ── Points boards (§7) ───────────────────────────────────────────────
@Test fun pointsBoardDecodesAnEmptyBoardAndItsCap() {
// A shard with nothing scored yet is the common case, not an error, and
// maxPoints 0 is the "uncapped" sentinel rather than a cap of zero.
val board = json.decodeFromString<PointsBoardDto>(
"""{"kind":"points.board","system":"QueensLoyalty","nameNumber":1095163,
"nameString":null,"players":0,"maxPoints":15000,"showOnGump":true,
"top":[],"t":1785556444154,"updatedAt":"2026-08-01T03:54:04.000Z"}""",
)
assertEquals("QueensLoyalty", board.system)
assertEquals(15000L, board.maxPoints)
assertNull(board.nameString)
assertTrue(board.top.isEmpty())
}
// ── Ruleset (§5) ─────────────────────────────────────────────────────
@Test fun rulesetDecodesTheNestedSectionsAndTolerantlySkipsUnknownOnes() {
// The frame is built from an allowlist that grows with the shard's config; a
// key this client has never heard of must not break the rules page.
val ruleset = json.decodeFromString<RulesetDto>(
"""{"kind":"world.ruleset","shard":"My Shard","expansion":"EJ",
"caps":{"skill":1000,"totalSkill":7000,"stat":225,"str":125},
"systems":{"factions":false,"vvv":true,"siege":false},
"accounts":{"charSlots":7,"perIp":3},
"somethingAddedLater":{"nested":true}}""",
)
assertEquals("My Shard", ruleset.shard)
assertEquals("EJ", ruleset.expansion)
// Caps arrive in tenths; the DTO's computed property is what the screen shows.
assertEquals(7000, ruleset.caps?.totalSkill)
assertEquals(700.0, ruleset.caps?.totalSkillCap!!, 0.0)
assertEquals(false, ruleset.systems["factions"])
assertEquals(true, ruleset.systems["vvv"])
}
}

View File

@@ -111,4 +111,30 @@ class ShardDtoTest {
assertEquals("bob", ActorDto(acct = "bob").label)
assertEquals("Someone", ActorDto().label)
}
@Test fun actorArrivesWithoutAcctOrWebIdBelowTheAdminRung() {
// Those two fields are locked to `admin` by the visibility framework and are
// stripped from every response below it — the app must decode their absence,
// not depend on them (docs/link/v3.md §3.4 rule 1).
val dto = json.decodeFromString<ActorDto>("""{"serial":"0x24C","name":"Darrow","player":true}""")
assertEquals("Darrow", dto.label)
assertNull(dto.acct)
assertNull(dto.webId)
}
@Test fun shardFeaturesDecodesTheRungAndVisibleSet() {
val dto = json.decodeFromString<ShardFeaturesDto>(
"""{"level":"player","features":["status","champs","guilds","market"]}""",
)
assertEquals("player", dto.level)
assertTrue(dto.features.contains("market"))
assertEquals(4, dto.features.size)
}
@Test fun shardFeaturesDecodesAnEmptySet() {
// A fully-gated shard: every feature switched off for this viewer. Distinct
// from the lookup failing, which the repository represents as null.
val dto = json.decodeFromString<ShardFeaturesDto>("""{"level":"anonymous","features":[]}""")
assertEquals(emptyList<String>(), dto.features)
}
}

View File

@@ -18,6 +18,15 @@ import com.runicgateway.app.data.api.dto.PageDto
import com.runicgateway.app.data.api.dto.PostDto
import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.SettingsDto
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
import com.runicgateway.app.data.api.dto.AtlasMetaDto
import com.runicgateway.app.data.api.dto.MarketMetaDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import com.runicgateway.app.data.api.dto.StatusDto
import com.runicgateway.app.data.api.dto.WikiCategoryDto
@@ -56,6 +65,25 @@ class FakePublicApi : PublicApi {
var governors: List<GovernorDto> = emptyList()
var governorHistory: List<GovernorTermDto> = emptyList()
var houses: List<HouseDto> = emptyList()
var shardFeatures: ShardFeaturesDto = ShardFeaturesDto()
// Protocol 3.0 content (M11). `ruleset` is nullable on the wire: null means the
// shard has never published one, which is a success, not a failure.
var ruleset: RulesetDto? = null
var pointsBoards: List<PointsBoardDto> = emptyList()
var pointsBoard: PointsBoardDto = PointsBoardDto()
var market: MarketPageDto = MarketPageDto()
var marketMeta: MarketMetaDto = MarketMetaDto()
var marketVendor: MarketVendorDto = MarketVendorDto()
var atlasCreatures: AtlasCreaturePageDto = AtlasCreaturePageDto()
var atlasCreature: AtlasCreatureDto = AtlasCreatureDto()
var atlasMeta: AtlasMetaDto = AtlasMetaDto()
/** Last market query seen, so a test can assert blanks were dropped. */
var lastMarketQuery: String? = null
/** Last atlas facet filter seen. */
var lastAtlasFacet: String? = null
/** Last contact request body seen (so a test can assert it was trimmed/forwarded). */
var lastContact: ContactRequest? = null
@@ -84,6 +112,41 @@ class FakePublicApi : PublicApi {
return reply(contactResponse)
}
override suspend fun getShardFeatures(): ShardFeaturesDto = reply(shardFeatures)
override suspend fun getShardRuleset(): RulesetDto? = reply(ruleset)
override suspend fun getShardPoints(): List<PointsBoardDto> = reply(pointsBoards)
override suspend fun getShardPointsBoard(system: String): PointsBoardDto = reply(pointsBoard)
override suspend fun getShardMarket(
query: String?,
minPrice: Long?,
maxPrice: Long?,
map: String?,
region: String?,
sort: String?,
limit: Int?,
offset: Int?,
): MarketPageDto {
lastMarketQuery = query
return reply(market)
}
override suspend fun getShardMarketMeta(): MarketMetaDto = reply(marketMeta)
override suspend fun getShardMarketVendor(serial: String, limit: Int?, offset: Int?): MarketVendorDto =
reply(marketVendor)
override suspend fun getAtlasCreatures(
query: String?,
facet: String?,
limit: Int?,
offset: Int?,
): AtlasCreaturePageDto {
lastAtlasFacet = facet
return reply(atlasCreatures)
}
override suspend fun getAtlasCreature(slug: String): AtlasCreatureDto = reply(atlasCreature)
override suspend fun getAtlasMeta(): AtlasMetaDto = reply(atlasMeta)
override suspend fun getShardStatus(): ShardStatusDto = reply(shardStatus)
override suspend fun getShardFeed(kind: String?, limit: Int?): List<FeedEventDto> = reply(shardFeed)
override suspend fun getShardEconomy(limit: Int?): List<EconomySampleDto> = reply(shardEconomy)

View File

@@ -0,0 +1,57 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.appearance
import kotlinx.serialization.json.JsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The second-stage parse of a JSON-valued settings row (THEMING_AND_NAV.md §3).
* The rule under test is the one the web client's `parseJsonSetting` states:
* anything that is not a plain object reads as **absent**, never as an error.
*/
class SettingsJsonTest {
@Test fun parsesAPlainObject() {
val parsed = parseJsonSetting("""{"/wiki":{"label":"Codex","order":0}}""")
assertEquals(1, parsed!!.size)
assertEquals(setOf("/wiki"), parsed.keys)
}
@Test fun parsesTheWrappedPublicShape() {
val parsed = parseJsonSetting(
"""{"items":{"/":{"hidden":true}},"sections":[{"id":"s1","label":"Play"}],"links":[]}""",
)
assertEquals(setOf("items", "sections", "links"), parsed!!.keys)
}
@Test fun absentValuesReadAsNull() {
assertNull(parseJsonSetting(null))
assertNull(parseJsonSetting(""))
}
@Test fun malformedJsonReadsAsNull() {
assertNull(parseJsonSetting("{"))
assertNull(parseJsonSetting("""{"a":}"""))
assertNull(parseJsonSetting("not json at all"))
}
@Test fun nonObjectJsonReadsAsNull() {
// A stored `null`, number, string or array is as unusable to every
// consumer of these keys as a syntax error is.
assertNull(parseJsonSetting("null"))
assertNull(parseJsonSetting("4"))
assertNull(parseJsonSetting("\"x\""))
assertNull(parseJsonSetting("[]"))
}
@Test fun unusualKeysAndValuesSurviveVerbatim() {
// The parse stage validates the *kind*, not the shape — a nonsense entry
// is dropped later, by the phase that reads it.
val parsed = parseJsonSetting("""{"/site/news":{"order":"first"},"nonsense":7}""")
assertEquals(JsonPrimitive(7), parsed!!["nonsense"])
}
}

View File

@@ -0,0 +1,97 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.appearance
import com.runicgateway.app.data.api.dto.SettingsDto
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* [SiteAppearance.from] — the coercion between the settings payload and what the
* theme and the drawer read (THEMING_AND_NAV.md §2, §3).
*
* The claims that matter here are the two the milestone rests on: an untouched
* instance resolves to *nothing* (so the shipped app renders), and a bad token
* costs exactly its own token.
*/
class SiteAppearanceTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
private fun appearanceOf(body: String) =
SiteAppearance.from(json.decodeFromString<SettingsDto>(body))
@Test fun untouchedInstanceResolvesToNoOverrides() {
// No theme_visual row, no nav_public row: the shipped app, exactly (§2).
val appearance = appearanceOf("""{"brand":{"name":"UOMysticmoon"}}""")
assertTrue(appearance.theme.isEmpty())
assertNull(appearance.navPublic)
assertEquals("UOMysticmoon", appearance.brand?.name)
}
@Test fun failedSettingsCallIsTheSameAsNoOverrides() {
assertSame(SiteAppearance.NONE, SiteAppearance.from(null))
assertNull(SiteAppearance.NONE.brand)
assertTrue(SiteAppearance.NONE.theme.isEmpty())
assertNull(SiteAppearance.NONE.navPublic)
}
@Test fun resolvedThemeTokensAreReadAsAMap() {
val appearance = appearanceOf(
"""{"theme":{"--accent":"#c8a45c","--bg":"#1a1410","--radius-card":"10px",
"--shadow-card":"none","--sans":"Inter, sans-serif"}}""",
)
assertEquals("#c8a45c", appearance.theme["--accent"])
assertEquals("#1a1410", appearance.theme["--bg"])
assertEquals("10px", appearance.theme["--radius-card"])
assertEquals("none", appearance.theme["--shadow-card"])
assertEquals("Inter, sans-serif", appearance.theme["--sans"])
}
@Test fun anEmptyThemeMapIsTheSameAsAbsent() {
// The server returns null rather than {} — the app must not depend on that.
assertTrue(appearanceOf("""{"theme":{}}""").theme.isEmpty())
}
@Test fun aBadTokenCostsOnlyItself() {
// AC-2 in miniature at the decode boundary: a non-string or blank value is
// dropped field-by-field, and its neighbours still apply.
val appearance = appearanceOf(
"""{"theme":{"--accent":"#c8a45c","--bg":7,"--line":null,"--ink":" "}}""",
)
assertEquals(mapOf("--accent" to "#c8a45c"), appearance.theme)
}
@Test fun aThemeOfTheWrongKindDoesNotCostTheBrand() {
// The whole reason `theme` is modeled as a raw JsonElement: one unexpected
// value must not fail the decode and take brand and push down with it.
val appearance = appearanceOf(
"""{"theme":"not an object","brand":{"name":"UOMysticmoon","accent":"#7f99bd"},
"push":{"ntfyUrl":"https://ntfy.example.com"}}""",
)
assertTrue(appearance.theme.isEmpty())
assertEquals("#7f99bd", appearance.brand?.accent)
}
@Test fun navPublicIsParsedASecondTime() {
// It arrives as a JSON string inside a JSON object, because settings.value
// is TEXT.
val appearance = appearanceOf("""{"nav_public":"{\"/wiki\":{\"label\":\"Codex\"}}"}""")
assertEquals(setOf("/wiki"), appearance.navPublic?.keys)
}
@Test fun aMalformedNavPublicDoesNotCostTheTheme() {
val appearance = appearanceOf("""{"nav_public":"{oops","theme":{"--accent":"#c8a45c"}}""")
assertNull(appearance.navPublic)
assertEquals("#c8a45c", appearance.theme["--accent"])
}
}

View File

@@ -0,0 +1,104 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.repository
import com.runicgateway.app.data.api.dto.ShardFeaturesDto
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.util.httpError
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
/**
* The shard-visibility lookup (PLAN.md §9 M11). The behavior worth pinning is the
* FAIL-OPEN direction: an unknown answer must show every entry, because the server
* gates every call regardless and the alternative is a menu that flickers in.
*/
class ShardFeaturesRepositoryTest {
private val api = FakePublicApi()
private val repository = ShardFeaturesRepository(api)
@Test fun refreshPublishesTheVisibleSetAndTheServersRung() = runTest {
api.shardFeatures = ShardFeaturesDto(
level = "player",
features = listOf("status", "champs", "market"),
)
repository.refresh()
val features = repository.features.value
assertEquals("player", features?.level)
assertEquals(setOf("status", "champs", "market"), features?.visible)
}
@Test fun aFeatureTheServerOmittedIsNotVisible() = runTest {
api.shardFeatures = ShardFeaturesDto(level = "anonymous", features = listOf("status"))
repository.refresh()
assertTrue(canSee(repository.features.value, ShardFeature.STATUS))
assertFalse(canSee(repository.features.value, ShardFeature.MARKET))
}
@Test fun aFailedLookupFallsBackToUnknownRatherThanEmpty() = runTest {
// Empty and unknown are opposite answers: empty hides everything, unknown
// shows everything. A failure must never be read as "this shard publishes
// nothing".
api.error = IOException("offline")
repository.refresh()
assertNull(repository.features.value)
assertTrue(canSee(repository.features.value, ShardFeature.MARKET))
}
@Test fun aPreProtocol3WebsiteIs404AndReadsAsUnknown() = runTest {
// The route does not exist before Protocol 3.0. That site has no visibility
// framework at all, so "unknown" is exactly right and the menu behaves as it
// did before M11.
api.error = httpError(404)
repository.refresh()
assertNull(repository.features.value)
assertTrue(canSee(repository.features.value, ShardFeature.CHAMPS))
}
@Test fun aFailedRefreshClearsAPreviouslyGoodAnswer() = runTest {
api.shardFeatures = ShardFeaturesDto(level = "admin", features = listOf("status"))
repository.refresh()
assertEquals(setOf("status"), repository.features.value?.visible)
// Signing out and failing to re-resolve must not leave the previous viewer's
// (possibly wider) answer in place.
api.error = httpError(500)
repository.refresh()
assertNull(repository.features.value)
}
@Test fun invalidateDropsTheCachedAnswer() = runTest {
api.shardFeatures = ShardFeaturesDto(level = "staff", features = listOf("houses"))
repository.refresh()
assertEquals("staff", repository.features.value?.level)
// A Settings → Server switch: the answer belonged to the old host.
repository.invalidate()
assertNull(repository.features.value)
}
@Test fun canSeeTreatsUnknownAsVisibleAndEmptyAsHidden() {
assertTrue("unknown must fail open", canSee(null, ShardFeature.RULESET))
assertFalse(
"an explicit empty set hides everything",
canSee(ShardFeatures(level = "anonymous", visible = emptySet()), ShardFeature.RULESET),
)
}
}

View File

@@ -38,15 +38,22 @@ class ContentViewModelTest {
private val settings = SettingsRepository(api)
// ── News hub ──────────────────────────────────────────────────────────
/** No category argument: how every route into the hub but §6.2's arrives. */
private fun newsViewModel(category: String? = null) =
NewsViewModel(
content,
SavedStateHandle(category?.let { mapOf(Routes.Args.CATEGORY to it) } ?: emptyMap()),
)
@Test fun newsLoadsSelectedCategory() {
api.posts = listOf(PostDto(id = 1, category = "news", title = "Hi"))
val vm = NewsViewModel(content)
val vm = newsViewModel()
assertTrue(vm.state.value is UiState.Success)
assertEquals(1, (vm.state.value as UiState.Success).data.size)
}
@Test fun newsSelectCategoryReloads() {
val vm = NewsViewModel(content)
val vm = newsViewModel()
api.posts = listOf(PostDto(id = 2, category = "newsletter", title = "N"))
vm.selectCategory(ContentRepository.PostCategory.NEWSLETTER)
assertEquals(ContentRepository.PostCategory.NEWSLETTER, vm.category.value)
@@ -55,7 +62,20 @@ class ContentViewModelTest {
@Test fun newsServerErrorIsUiError() {
api.error = httpError(500)
assertTrue(NewsViewModel(content).state.value is UiState.Error)
assertTrue(newsViewModel().state.value is UiState.Error)
}
@Test fun newsOpensOnTheCategoryTheRouteAsksFor() {
// The app's half of an admin's nav override or added link pointing at one of
// the website's three category pages (THEMING_AND_NAV.md §6.2).
val vm = newsViewModel("five-on-friday")
assertEquals(ContentRepository.PostCategory.FIVE_ON_FRIDAY, vm.category.value)
}
@Test fun newsFallsBackToTheDefaultFeedForAnUnknownCategory() {
// A hand-edited settings row, or a category the site has and the app doesn't.
assertEquals(ContentRepository.PostCategory.NEWS, newsViewModel("bogus").category.value)
assertEquals(ContentRepository.PostCategory.NEWS, newsViewModel().category.value)
}
// ── Post detail (SavedStateHandle args) ─────────────────────────────────

View File

@@ -4,7 +4,9 @@
package com.runicgateway.app.ui
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.ui.components.isRetryable
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.IOException
@@ -34,6 +36,44 @@ class UiStateTest {
assertTrue(ApiResult.HttpError(503).let { it.status == 503 })
}
// ── Shard reads: 404/403 mean "this shard doesn't publish it" (M11) ──
@Test fun shardReadsTreat404And403AsFeatureUnavailable() {
// requireFeature answers 404 for a disabled feature (deliberately not
// disclosing that it exists) and 403 for a viewer below its audience rung.
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, shardKindOf(404))
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, shardKindOf(403))
}
@Test fun shardReadsLeaveEveryOtherStatusAlone() {
assertEquals(ErrorKind.SHARD_OFFLINE, shardKindOf(503))
assertEquals(ErrorKind.RATE_LIMITED, shardKindOf(429))
assertEquals(ErrorKind.SERVER, shardKindOf(500))
assertEquals(
ErrorKind.NETWORK,
(ApiResult.NetworkError(IOException()).toShardUiState() as UiState.Error).kind,
)
assertEquals(UiState.Success("hi"), ApiResult.Ok("hi").toShardUiState())
}
@Test fun nonShardReadsKeep404AsNotFound() {
// The remap is scoped to shard routes on purpose: off them, a 404 is still a
// deleted post or an unknown wiki slug.
assertEquals(ErrorKind.NOT_FOUND, kindOf(404))
}
@Test fun anUnavailableFeatureIsNotRetryable() {
// An admin controls this, so a retry button would read as a transient failure
// the user could wait out.
assertFalse(isRetryable(ErrorKind.FEATURE_UNAVAILABLE))
for (kind in ErrorKind.entries.filter { it != ErrorKind.FEATURE_UNAVAILABLE }) {
assertTrue("$kind should offer a retry", isRetryable(kind))
}
}
private fun kindOf(status: Int): ErrorKind =
(ApiResult.HttpError(status).toUiState() as UiState.Error).kind
private fun shardKindOf(status: Int): ErrorKind =
(ApiResult.HttpError(status).toShardUiState() as UiState.Error).kind
}

View File

@@ -0,0 +1,104 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.components
import com.runicgateway.app.data.api.dto.BrandDto
import com.runicgateway.app.data.appearance.SiteAppearance
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* §5.6's one testable rule: **an empty slot resolves to nothing.** The drawing
* itself is out of reach here — the app carries no Robolectric, so a composable
* body cannot run in a JVM test and phase 4's layout is AC-5's job — but the
* decision of whether to draw at all is pure, and it is the decision that keeps
* an unbranded instance laying out as it did before M12.
*
* The resolver is faked as the absolute-URL join the real one performs
* (`AppViewModel.resolveAsset`, unchanged by this phase), so these assert
* [brandAssetUrl]'s own contract rather than re-testing the network layer.
*/
class BrandAssetsTest {
private val resolve: (String?) -> String? = { path ->
when {
path.isNullOrBlank() -> null
path.startsWith("http") -> path
else -> "https://shard.example${if (path.startsWith("/")) "" else "/"}$path"
}
}
// --- the empty slot: every shape "not set" arrives in ------------------
@Test
fun `a null slot resolves to nothing`() {
assertNull(brandAssetUrl(null, resolve))
}
@Test
fun `an empty slot resolves to nothing`() {
// The server publishes "" for an asset that was never uploaded, and BrandDto
// defaults to it — this is the case that carries the untouched instance.
assertNull(brandAssetUrl("", resolve))
}
@Test
fun `a whitespace-only slot resolves to nothing`() {
assertNull(brandAssetUrl(" ", resolve))
}
@Test
fun `the shipped brand has neither a logo nor a hero`() {
// AC-1 for phase 4: nothing about a default BrandDto puts an image on screen.
val brand = BrandDto()
assertNull(brandAssetUrl(brand.logo, resolve))
assertNull(brandAssetUrl(brand.hero, resolve))
}
@Test
fun `a failed settings load leaves no brand to draw`() {
// SiteAppearance.NONE is what a dead backend produces (§2). It has no brand
// at all, so both slots are absent rather than empty.
val brand: BrandDto? = SiteAppearance.NONE.brand
assertNull(brand)
assertNull(brandAssetUrl(brand?.logo, resolve))
assertNull(brandAssetUrl(brand?.hero, resolve))
}
// --- the filled slot ---------------------------------------------------
@Test
fun `a site-relative upload resolves against the shard's base`() {
assertEquals(
"https://shard.example/uploads/brand/logo.png",
brandAssetUrl("/uploads/brand/logo.png", resolve),
)
}
@Test
fun `an absolute URL passes through`() {
// BRAND_LOGO may be set to an off-site URL; the resolver leaves those alone.
assertEquals(
"https://cdn.example/logo.svg",
brandAssetUrl("https://cdn.example/logo.svg", resolve),
)
}
// --- the second blank check -------------------------------------------
@Test
fun `a resolver that returns nothing resolves to nothing`() {
// No base URL configured yet: the real resolver hands the path back or gives
// up. Either way the slot must not become an image request.
assertNull(brandAssetUrl("/uploads/brand/logo.png") { null })
}
@Test
fun `a resolver that returns blank resolves to nothing`() {
// Why the blank check is on both sides of the resolver, not just the input.
assertNull(brandAssetUrl("/uploads/brand/logo.png") { "" })
assertNull(brandAssetUrl("/uploads/brand/logo.png") { " " })
}
}

View File

@@ -0,0 +1,108 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.core.auth.Role
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionUser
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import com.runicgateway.app.ui.shard.ShardBoard
import com.runicgateway.app.ui.shard.visibleBoards
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The second gate on a shard entry (PLAN.md §5, §9 M11): the shard's admin-configured
* visibility, independent of the session role. A signed-in admin still doesn't see a
* board the shard doesn't publish, and an anonymous visitor still doesn't see a
* signed-in entry however wide the feature config is.
*/
class MenuFeatureGatingTest {
private fun signedIn(role: Role) =
Session.SignedIn(SessionUser(id = 1, username = "u", role = role))
private fun features(vararg visible: String) =
ShardFeatures(level = "anonymous", visible = visible.toSet())
private val shardEntry = MenuEntry("shard", 0, MenuAccess.PUBLIC, feature = ShardFeature.STATUS)
private val plainEntry = MenuEntry("news", 0, MenuAccess.PUBLIC)
@Test fun aShardEntryHidesWhenItsFeatureIsNotVisible() {
val entries = listOf(plainEntry, shardEntry)
val visible = visibleEntries(entries, Session.SignedOut, features("champs")).map { it.route }
assertEquals(listOf("news"), visible)
}
@Test fun aShardEntryShowsWhenItsFeatureIsVisible() {
val entries = listOf(plainEntry, shardEntry)
val visible = visibleEntries(entries, Session.SignedOut, features("status")).map { it.route }
assertEquals(listOf("news", "shard"), visible)
}
@Test fun unknownFeaturesShowEverythingTheRoleAllows() {
// Fail open while the lookup is in flight or has failed — the server gates
// regardless, so a link that briefly 403s beats a nav that flickers in.
val entries = listOf(plainEntry, shardEntry)
val visible = visibleEntries(entries, Session.SignedOut, features = null).map { it.route }
assertEquals(listOf("news", "shard"), visible)
}
@Test fun theTwoGatesAreIndependent() {
val staffShardEntry = MenuEntry("s", 0, MenuAccess.STAFF, feature = ShardFeature.HOUSES)
val entries = listOf(staffShardEntry)
// Right role, feature switched off → hidden.
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN), features("champs")).isEmpty())
// Feature on, wrong role → hidden.
assertTrue(visibleEntries(entries, signedIn(Role.PLAYER), features("houses")).isEmpty())
// Both → shown.
assertFalse(visibleEntries(entries, signedIn(Role.ADMIN), features("houses")).isEmpty())
}
@Test fun anAdminDoesNotBypassAFeatureGate() {
// The rung the server placed the caller on is what /features already accounts
// for. A staff role is not a licence to render a link to a disabled feature —
// a disabled feature 404s for everyone.
val entries = listOf(shardEntry)
assertTrue(visibleEntries(entries, signedIn(Role.ADMIN), features()).isEmpty())
}
@Test fun everyShardMenuEntryDeclaresAFeature() {
// A shard-derived entry with no feature name silently skips the gate. The app
// menu's only such entry today is the Shard hub; this fails if one is added
// without one.
val shardRoutes = APP_MENU.filter { it.route == Routes.SHARD }
assertTrue(shardRoutes.isNotEmpty())
assertTrue(shardRoutes.all { it.feature != null })
}
// ── The hub's board tiles use the same gate ──────────────────────────
@Test fun hubBoardsAreFilteredByFeature() {
val visible = visibleBoards(features("champs", "houses"))
assertEquals(listOf(ShardBoard.CHAMPS, ShardBoard.HOUSES), visible)
}
@Test fun hubBoardsShowAllWhenTheAnswerIsUnknown() {
assertEquals(ShardBoard.entries.toList(), visibleBoards(null))
}
@Test fun eachBoardMapsToItsOwnFeature() {
assertEquals(ShardFeature.CHAMPS, ShardBoard.CHAMPS.feature)
assertEquals(ShardFeature.GUILDS, ShardBoard.GUILDS.feature)
assertEquals(ShardFeature.GOVERNORS, ShardBoard.GOVERNORS.feature)
assertEquals(ShardFeature.HOUSES, ShardBoard.HOUSES.feature)
}
}

View File

@@ -0,0 +1,271 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.core.auth.Role
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionUser
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The public-nav override merge (THEMING_AND_NAV.md §6): label, order and hidden,
* applied to the coded [APP_MENU] and nothing else.
*
* Two things these tests are really about. **AC-1** — an instance whose admin never
* touched the nav must get the drawer the app shipped with, which here is the
* strongest possible assertion: the same list instance back. And **AC-3** — the
* merge runs before [visibleEntries] and cannot reach past it, so a `hidden: false`
* on a gated row still shows nothing.
*/
class NavOverridesTest {
private fun nav(vararg items: Pair<String, JsonObject>): JsonObject =
buildJsonObject { for ((path, entry) in items) put(path, entry) }
private fun entry(
label: String? = null,
order: Int? = null,
hidden: Boolean? = null,
): JsonObject = buildJsonObject {
label?.let { put("label", it) }
order?.let { put("order", it) }
hidden?.let { put("hidden", it) }
}
private fun routes(nav: JsonObject?) = applyNavOverrides(APP_MENU, nav).map { it.route }
/** The public block's routes, in coded order — the first nine of APP_MENU. */
private val codedPublic = listOf(
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.page("about"),
)
// ── AC-1: the untouched instance ─────────────────────────────────────
@Test fun noStoredRowReturnsTheCodedMenuItself() {
// Identity, not equality: the drawer of an instance that never edited its
// nav is the shipped one, and nothing was rebuilt to arrive at it.
assertSame(APP_MENU, applyNavOverrides(APP_MENU, null))
}
@Test fun anEmptyRowReturnsTheCodedMenuItself() {
assertSame(APP_MENU, applyNavOverrides(APP_MENU, buildJsonObject { }))
}
@Test fun aRowWithNothingUsableInItReturnsTheCodedMenuItself() {
// A blank label, a non-finite order, `hidden: false`, a path the app has no
// screen for, and a path it maps but doesn't put in the drawer. None of it
// says anything, so none of it may cost the coded menu.
val stored = nav(
"/" to entry(label = " "),
"/site/news" to entry(hidden = false),
"/admin/appearance" to entry(label = "Nope"),
"/site/screenshots" to entry(label = "Shots", order = 0),
"/site/champs" to entry(hidden = true),
)
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
}
@Test fun aMalformedEntryIsDroppedAndItsNeighbourKept() {
val stored = buildJsonObject {
put("/site/news", "not an object")
put("/wiki", entry(label = "Codex"))
}
val merged = applyNavOverrides(APP_MENU, stored)
assertEquals(codedPublic, merged.take(9).map { it.route })
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
assertNull(merged.first { it.route == Routes.NEWS }.label)
}
// ── Labels ───────────────────────────────────────────────────────────
@Test fun aLabelOverridesTheBundledString() {
val merged = applyNavOverrides(APP_MENU, nav("/site/shard" to entry(label = " The Realm ")))
val shard = merged.first { it.route == Routes.SHARD }
assertEquals("The Realm", shard.label)
// The override lands on `label` and nothing else — the gates are untouched.
assertEquals(ShardFeature.STATUS, shard.feature)
assertEquals(MenuAccess.PUBLIC, shard.access)
assertEquals(codedPublic, merged.take(9).map { it.route })
}
@Test fun aNonStringLabelIsIgnored() {
val stored = buildJsonObject { put("/wiki", buildJsonObject { put("label", 7) }) }
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
}
// ── Hidden ───────────────────────────────────────────────────────────
@Test fun hiddenDropsTheRow() {
val routes = routes(nav("/site/market" to entry(hidden = true)))
assertTrue(Routes.SHARD_MARKET !in routes)
assertEquals(APP_MENU.size - 1, routes.size)
}
@Test fun homeCanBeHidden() {
// Mirrors the website, where `/` is hideable too. Home stays the NavHost's
// start destination and stays reachable by back-press; the app does not
// invent a policy the site doesn't have.
val routes = routes(nav("/" to entry(hidden = true)))
assertTrue(Routes.HOME !in routes)
}
@Test fun hiddenFalseHidesNothing() {
assertSame(APP_MENU, applyNavOverrides(APP_MENU, nav("/site/market" to entry(hidden = false))))
}
@Test fun hiddenWinsOverALabelOnTheSameRow() {
val routes = routes(nav("/wiki" to entry(label = "Codex", hidden = true)))
assertTrue(Routes.WIKI !in routes)
}
// ── Order ────────────────────────────────────────────────────────────
@Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() {
// The website's own indices: About is 15 and Home is 0, so swapping them
// is what an admin dragging About to the top writes.
val routes = routes(
nav(
"/site/about" to entry(order = 0),
"/" to entry(order = 15),
),
)
assertEquals(
listOf(
Routes.page("about"), Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.HOME,
),
routes.take(9),
)
}
@Test fun anUntouchedRowKeepsItsPlaceOnTheWebsitesNumberLine() {
// The tie-break that needs the website's order rather than the app's: an
// explicit 5 meets Wiki's implicit 5 (its index in the site's nav, where
// the three news categories sit between News and Wiki). Explicit wins.
val routes = routes(nav("/site/about" to entry(order = 5)))
assertEquals(
listOf(Routes.HOME, Routes.NEWS, Routes.page("about"), Routes.WIKI),
routes.take(4),
)
}
@Test fun theAppsOwnRowsKeepTheirCodedOrderAfterThePublicBlock() {
// Contact, Account, Notifications, the three player groups and the four
// staff rows have no website counterpart to be reordered against (§6.2).
val tail = APP_MENU.drop(9).map { it.route }
val merged = routes(nav("/site/about" to entry(order = 0)))
assertEquals(tail, merged.drop(9))
}
@Test fun reorderingAndHidingCompose() {
val routes = routes(
nav(
"/site/about" to entry(order = 0),
"/" to entry(hidden = true),
),
)
assertEquals(Routes.page("about"), routes.first())
assertTrue(Routes.HOME !in routes)
}
// ── The two stored shapes ────────────────────────────────────────────
@Test fun theWrappedShapeIsRead() {
// Website phase 10 wraps the map as {items, sections, links} without
// migrating what phases 6-8 stored bare, so both shapes are live.
val stored = buildJsonObject {
put("items", nav("/wiki" to entry(label = "Codex")))
put("sections", buildJsonObject { })
put("links", buildJsonObject { })
}
val merged = applyNavOverrides(APP_MENU, stored)
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
}
@Test fun sectionsAndLinksDoNotDisturbTheItemsMerge() {
// This merge is items-only; `buildNavTree` is what renders the structure
// around them (§6.3), and it leans on this staying true — an `items` map
// that says nothing still returns the coded menu itself.
val stored = buildJsonObject {
put("items", buildJsonObject { })
put("sections", buildJsonObject { put("id", "lore") })
}
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
}
// ── AC-3: the merge cannot reach past the gates ──────────────────────
@Test fun anOverrideCannotUnhideAFeatureGatedRow() {
val stored = nav(
"/site/market" to entry(label = "Bazaar", hidden = false, order = 0),
)
val visible = visibleEntries(
applyNavOverrides(APP_MENU, stored),
Session.SignedIn(SessionUser(id = 1, username = "u", role = Role.ADMIN)),
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
).map { it.route }
// Relabeled and moved to the front, and still not shown: the shard does not
// publish the market, and an admin does not outrank that.
assertTrue(Routes.SHARD_MARKET !in visible)
assertTrue(Routes.SHARD in visible)
}
@Test fun anOverrideCannotUnhideARoleGatedRow() {
val stored = nav("/" to entry(order = 99))
val visible = visibleEntries(
applyNavOverrides(APP_MENU, stored),
Session.SignedOut,
features = null,
).map { it.route }
assertTrue(Routes.ACCOUNT !in visible)
assertTrue(Routes.ADMIN_DASHBOARD !in visible)
assertTrue(Routes.PLAYER_CHARACTERS !in visible)
}
@Test fun theGatesRunOnTheMergedListNotTheCodedOne() {
// Hiding is subtractive on top of the gates, so the two compose: the row an
// admin hid is gone, and so is the row this caller may not see.
val stored = nav("/wiki" to entry(hidden = true))
val visible = visibleEntries(
applyNavOverrides(APP_MENU, stored),
Session.SignedOut,
ShardFeatures(level = "anonymous", visible = setOf(ShardFeature.STATUS)),
).map { it.route }
assertTrue(Routes.WIKI !in visible)
assertTrue(Routes.SHARD_MARKET !in visible)
assertTrue(Routes.HOME in visible)
}
}

View File

@@ -0,0 +1,180 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.data.repository.ContentRepository.PostCategory
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* The website path → app route table (THEMING_AND_NAV.md §6.2).
*
* This is the milestone's one piece of cross-repo coupling, so the tests are
* mostly about the table's *shape* — that it stays complete, unambiguous, and
* honest about which rows the app actually surfaces in its drawer.
*/
class NavPathsTest {
@Test fun everyWebsiteNavPathIsMapped() {
// The sixteen rows of SiteHeader.jsx's NAV, quoted in NavPaths.kt. If the
// site adds one, this is the test that says so — a path with no mapping is
// silently unresolvable in phase 6's link handling.
assertEquals(16, WEBSITE_PUBLIC_NAV.size)
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size)
}
@Test fun everyMappedRouteIsDistinct() {
// WEB_ROUTE_ORDER is keyed by route, so a duplicate would silently drop a
// row's position from the sort.
assertEquals(WEBSITE_PUBLIC_NAV.size, WEBSITE_PUBLIC_NAV.map { it.route }.toSet().size)
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_ROUTE_ORDER.size)
}
@Test fun theWebsitesOrderIsPreserved() {
// Load-bearing: a stored `order` is an index into this list.
assertEquals(0, WEB_ROUTE_ORDER[Routes.HOME])
assertEquals(1, WEB_ROUTE_ORDER[Routes.NEWS])
assertEquals(5, WEB_ROUTE_ORDER[Routes.WIKI])
assertEquals(15, WEB_ROUTE_ORDER[Routes.page("about")])
}
@Test fun theNineDrawerRowsAreTheIntersectionWithAppMenu() {
// Nine of the sixteen have a drawer row. The other seven are mapped but not
// surfaced — three news category tabs and the four Shard hub boards — and
// an override for one of them is ignored rather than obeyed (§6.2).
val coded = APP_MENU.map { it.route }.toSet()
val surfaced = WEBSITE_PUBLIC_NAV.filter { it.route in coded }.map { it.path }
assertEquals(
listOf(
"/", "/site/news", "/wiki", "/site/shard", "/site/rules",
"/site/atlas", "/site/leaderboards", "/site/market", "/site/about",
),
surfaced,
)
}
@Test fun theSevenUnsurfacedPathsStillResolveToAScreen() {
// Phase 6's added links resolve against the same table, and there a category
// tab or a hub board is a perfectly good destination.
val unsurfaced = listOf(
"/site/screenshots", "/site/five-on-friday", "/site/newsletter",
"/site/champs", "/site/guilds", "/site/governors", "/site/houses",
)
assertTrue(unsurfaced.all { appRouteForWebPath(it) != null })
assertTrue(unsurfaced.none { appRouteForWebPath(it) in APP_MENU.map { e -> e.route } })
}
@Test fun theNewsCategoriesMapToTheirTab() {
assertEquals("news?category=screenshots", appRouteForWebPath("/site/screenshots"))
assertEquals("news?category=five-on-friday", appRouteForWebPath("/site/five-on-friday"))
assertEquals("news?category=newsletter", appRouteForWebPath("/site/newsletter"))
// The plain news path is the un-argumented route, so it matches the drawer's
// coded row and opens the default tab.
assertEquals(Routes.NEWS, appRouteForWebPath("/site/news"))
}
@Test fun theCategoryRouteMatchesTheNavHostPattern() {
// The pattern the NavHost declares and the value callers navigate to have to
// agree on the query key, or the argument arrives as null and the screen
// silently opens the default tab.
assertEquals("news?category={category}", Routes.NEWS_ROUTE)
assertTrue(Routes.NEWS_ROUTE.startsWith("${Routes.NEWS}?"))
for (category in PostCategory.entries) {
assertEquals("${Routes.NEWS}?category=${category.urlSlug}", Routes.news(category))
}
}
@Test fun theRoutePatternStripsToTheTopLevelRoute() {
// How RunicApp recognizes the News destination: `destination.route` is the
// pattern, and the drawer's row is the bare route.
assertEquals(Routes.NEWS, Routes.NEWS_ROUTE.substringBefore('?'))
assertEquals(Routes.NEWS, Routes.news(PostCategory.NEWSLETTER).substringBefore('?'))
}
// ── Lookup hygiene ───────────────────────────────────────────────────
@Test fun anUnknownPathResolvesToNothing() {
assertNull(appRouteForWebPath("/admin/appearance"))
assertNull(appRouteForWebPath("/site/news/some-post"))
assertNull(appRouteForWebPath("https://elsewhere.example/"))
}
@Test fun blankAndNullResolveToNothing() {
assertNull(appRouteForWebPath(null))
assertNull(appRouteForWebPath(""))
assertNull(appRouteForWebPath(" "))
}
@Test fun aTrailingSlashIsTolerated() {
// A hand-edited settings row may carry one; the root is left alone.
assertEquals(Routes.WIKI, appRouteForWebPath("/wiki/"))
assertEquals(Routes.SHARD, appRouteForWebPath(" /site/shard/ "))
assertEquals(Routes.HOME, appRouteForWebPath("/"))
}
// ── resolveWebPath: an added link may name any page on the site (§6.3) ──
@Test fun theNavTablesSixteenPathsResolveTheSameWay() {
// An added link to a path the nav already knows must land where the nav row
// does, or the same destination would behave differently depending on how
// the admin reached it.
for (row in WEBSITE_PUBLIC_NAV) {
assertEquals(row.route, resolveWebPath(row.path))
}
}
@Test fun theSitesDetailRoutesResolve() {
// Read off website/client/src/App.jsx. Note what is NOT here: the site has
// no /site/news/<id> route — its one post-detail route is the newsletter's.
assertEquals(Routes.wikiPage("smithing"), resolveWebPath("/wiki/smithing"))
assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/site/atlas/dragon"))
assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/site/market/vendors/0x24C"))
assertEquals(Routes.post("newsletter", "12"), resolveWebPath("/site/newsletter/12"))
}
@Test fun aTopLevelSlugIsACmsPage() {
// The site serves CMS pages from a top-level /<slug>, so this is the rule
// that opens an admin's own page natively rather than in a browser.
assertEquals(Routes.page("donate"), resolveWebPath("/donate"))
assertEquals(Routes.page("about"), resolveWebPath("/site/about"))
}
@Test fun theSitesOwnSectionsAreNotCmsPages() {
// React Router ranks its static routes above /:slug, and so must the app —
// otherwise a link to the admin panel would open a 404 CMS page in-app
// instead of the real thing in a browser.
for (path in listOf("/admin", "/account", "/player", "/site", "/invite", "/preview", "/api", "/uploads")) {
assertNull(path, resolveWebPath(path))
}
// /wiki is reserved from the catch-all but mapped by the table above it.
assertEquals(Routes.WIKI, resolveWebPath("/wiki"))
}
@Test fun aPathTheAppHasNoScreenForHandsOff() {
assertNull(resolveWebPath("/site/status"))
assertNull(resolveWebPath("/site/shard/activity"))
assertNull(resolveWebPath("/account/login"))
assertNull(resolveWebPath("/admin/navigation"))
assertNull(resolveWebPath("/site/atlas/dragon/extra"))
}
@Test fun aQueryOrFragmentHandsOff() {
// No app route takes either, so a native match would quietly drop what the
// admin wrote. The browser honors it exactly.
assertNull(resolveWebPath("/site/news?tag=patch"))
assertNull(resolveWebPath("/donate#tiers"))
assertEquals(Routes.NEWS, resolveWebPath("/site/news"))
}
@Test fun aMalformedPathResolvesToNothing() {
assertNull(resolveWebPath(null))
assertNull(resolveWebPath(""))
assertNull(resolveWebPath("/site//news"))
assertNull(resolveWebPath("https://elsewhere.example/donate"))
}
}

View File

@@ -0,0 +1,455 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.navigation
import com.runicgateway.app.core.auth.Role
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionUser
import com.runicgateway.app.data.repository.ShardFeature
import com.runicgateway.app.data.repository.ShardFeatures
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertSame
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Drawer sections and added links (THEMING_AND_NAV.md §6.3) — the tree build and
* the gate that prunes it.
*
* Three things these tests are really about. **AC-1**: an admin who created no
* structure gets phase 5 back untouched, and an untouched instance gets the coded
* [APP_MENU] entries themselves. **AC-3**: [pruneNav] runs after the build and
* remains the boundary — including inside a section, and including the case where
* it empties one. And the link path rule, which is what keeps "an override may
* never introduce navigation" true of a feature whose whole job is to add entries:
* a link may name any page **on this site**, and nothing else.
*/
class NavTreeTest {
// ── Fixtures ─────────────────────────────────────────────────────────
private fun stored(
items: JsonObject = buildJsonObject { },
sections: List<JsonObject> = emptyList(),
links: List<JsonObject> = emptyList(),
): JsonObject = buildJsonObject {
put("items", items)
put("sections", buildJsonArray { sections.forEach { add(it) } })
put("links", buildJsonArray { links.forEach { add(it) } })
}
private fun items(vararg entries: Pair<String, JsonObject>): JsonObject =
buildJsonObject { for ((path, entry) in entries) put(path, entry) }
private fun item(
label: String? = null,
order: Int? = null,
hidden: Boolean? = null,
section: String? = null,
): JsonObject = buildJsonObject {
label?.let { put("label", it) }
order?.let { put("order", it) }
hidden?.let { put("hidden", it) }
section?.let { put("section", it) }
}
private fun section(id: String, label: String? = "Lore", order: Int? = null): JsonObject =
buildJsonObject {
put("id", id)
label?.let { put("label", it) }
order?.let { put("order", it) }
}
private fun link(
id: String = "l1",
label: String? = "Donate",
to: String? = "/donate",
order: Int? = null,
section: String? = null,
): JsonObject = buildJsonObject {
put("id", id)
label?.let { put("label", it) }
to?.let { put("to", it) }
order?.let { put("order", it) }
section?.let { put("section", it) }
}
private fun tree(navPublic: JsonObject?) = buildNavTree(APP_MENU, navPublic)
/** Top-level routes, with a section standing in as `section:<id>`. */
private fun List<NavNode>.shape(): List<String> = map {
when (it) {
is NavNode.Item -> it.entry.route
is NavNode.Link -> "link:${it.id}"
is NavNode.Section -> "section:${it.id}"
}
}
private fun List<NavNode>.section(id: String): NavNode.Section =
filterIsInstance<NavNode.Section>().first { it.id == id }
private fun List<NavNode>.link(id: String): NavNode.Link =
filterIsInstance<NavNode.Link>().first { it.id == id }
// ── AC-1: no structure means phase 5, unchanged ──────────────────────
@Test fun noStoredRowIsTheCodedMenu() {
val nodes = tree(null)
assertEquals(APP_MENU.size, nodes.size)
// The entries themselves, not copies: with nothing stored, nothing was
// rebuilt to arrive at the drawer the app shipped with.
APP_MENU.forEachIndexed { index, entry ->
assertSame(entry, (nodes[index] as NavNode.Item).entry)
}
}
@Test fun withoutSectionsOrLinksTheBuildIsTheFlatMerge() {
// Phase 6 adds structure; it does not re-implement phase 5. An items-only
// row must give exactly what applyNavOverrides gives.
val row = stored(items = items("/site/about" to item(order = 0)))
assertEquals(
applyNavOverrides(APP_MENU, row).map { it.route },
tree(row).shape(),
)
}
@Test fun malformedSectionsAndLinksAreNotStructure() {
// Wrong kinds where the arrays should be — a hand-edited row, or the bare
// items map phases 6-8 stored. Neither is structure, so neither may cost
// the coded menu.
val row = buildJsonObject {
put("items", buildJsonObject { })
put("sections", buildJsonObject { put("id", "lore") })
put("links", "nope")
}
assertEquals(APP_MENU.map { it.route }, tree(row).shape())
}
// ── Sections ─────────────────────────────────────────────────────────
@Test fun aSectionCollectsItsMembersBeneathIt() {
val row = stored(
items = items(
"/wiki" to item(section = "lore"),
"/site/about" to item(section = "lore"),
),
sections = listOf(section("lore", label = " The Realm ")),
)
val nodes = tree(row)
assertTrue(Routes.WIKI !in nodes.shape())
assertEquals("The Realm", nodes.section("lore").label)
assertEquals(
listOf(Routes.WIKI, Routes.page("about")),
nodes.section("lore").items.shape(),
)
}
@Test fun aSectionWithNoOrderAppendsAfterTheCodedRows() {
// An admin-created entity with no stored order appends in creation order
// rather than jumping to the front on a 0 default. The app's own rows stay
// behind it, where they already sit (§6.2).
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore")),
)
val shape = tree(row).shape()
// Eight public rows are left at the top level (Wiki moved into the section),
// then the section, then the app's own rows.
assertEquals("section:lore", shape[8])
assertEquals(Routes.CONTACT, shape[9])
}
@Test fun aSectionsOrderPlacesItAmongTheCodedRows() {
// Sections sort on the same number line as everything else: the website's
// sixteen indices, then admin-created entities after them.
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore", order = 0)),
)
assertEquals("section:lore", tree(row).shape().first())
}
@Test fun aSectionWithoutAUsableLabelIsDroppedAndItsMembersStayPut() {
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore", label = " ")),
)
val nodes = tree(row)
assertTrue(nodes.filterIsInstance<NavNode.Section>().isEmpty())
// The section never existed, so the reference to it is dangling and the row
// is an ordinary top-level one — not a row that vanished with its section.
assertTrue(Routes.WIKI in nodes.shape())
}
@Test fun aRepeatedSectionIdKeepsTheFirst() {
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore", label = "First"), section("lore", label = "Second")),
)
val sections = tree(row).filterIsInstance<NavNode.Section>()
assertEquals(1, sections.size)
assertEquals("First", sections.single().label)
}
@Test fun anItemNamingAnUnknownSectionStaysTopLevel() {
val row = stored(
items = items("/wiki" to item(section = "nope")),
sections = listOf(section("lore")),
)
val nodes = tree(row)
assertTrue(Routes.WIKI in nodes.shape())
assertTrue(nodes.section("lore").items.isEmpty())
}
@Test fun aHiddenItemIsDroppedEvenInsideASection() {
val row = stored(
items = items("/wiki" to item(hidden = true, section = "lore")),
sections = listOf(section("lore")),
)
val nodes = tree(row)
assertTrue(Routes.WIKI !in nodes.shape())
assertTrue(nodes.section("lore").items.isEmpty())
}
@Test fun aLabelStillLandsOnASectionedRow() {
val row = stored(
items = items("/wiki" to item(label = "Codex", section = "lore")),
sections = listOf(section("lore")),
)
val wiki = tree(row).section("lore").items.filterIsInstance<NavNode.Item>().single()
assertEquals("Codex", wiki.entry.label)
// The override lands on the label and nothing else — the gates are untouched.
assertEquals(MenuAccess.PUBLIC, wiki.entry.access)
assertNull(wiki.entry.feature)
}
@Test fun aSectionRequestForARowTheDrawerDoesNotSurfaceIsIgnored() {
// Same rule as phase 5's: the app puts the hub boards behind the Shard hub
// deliberately, and grouping is no more an invitation to surface one than
// relabelling was (§6.2).
val row = stored(
items = items("/site/champs" to item(section = "lore", label = "Champs")),
sections = listOf(section("lore")),
)
val nodes = tree(row)
assertTrue(nodes.section("lore").items.isEmpty())
assertTrue(Routes.SHARD_CHAMPS !in nodes.shape())
}
// ── Added links ──────────────────────────────────────────────────────
@Test fun aLinkTheAppCanResolveCarriesItsRoute() {
val row = stored(links = listOf(link(to = "/wiki/smithing")))
assertEquals(Routes.wikiPage("smithing"), tree(row).link("l1").route)
}
@Test fun aLinkTheAppCannotResolveHandsOff() {
// A null route is the Custom Tab; the path is kept verbatim so the browser
// gets exactly what the admin wrote.
val row = stored(links = listOf(link(to = "/site/status")))
val node = tree(row).link("l1")
assertNull(node.route)
assertEquals("/site/status", node.path)
}
@Test fun aLinkThatWouldLeaveTheOriginIsDropped() {
// The website's own read rule, ported: a stored value that is not a
// single-slash site path is dropped rather than rendered, so a hand-edited
// row cannot put an off-site link in the drawer.
val bad = listOf(
"//evil.example/x", "https://evil.example", "donate", "/don ate",
"/don\"ate", "/don'ate", "/don<ate", "/don\\ate",
)
for (to in bad) {
assertTrue(to, tree(stored(links = listOf(link(to = to)))).filterIsInstance<NavNode.Link>().isEmpty())
}
}
@Test fun aLinkWithoutAnIdLabelOrPathIsDropped() {
val row = stored(
links = listOf(
buildJsonObject {
put("label", "No id")
put("to", "/a")
},
link(id = "no-label", label = null),
link(id = "no-to", to = null),
link(id = "blank-label", label = " "),
link(id = "good"),
),
)
assertEquals(listOf("good"), tree(row).filterIsInstance<NavNode.Link>().map { it.id })
}
@Test fun aRepeatedLinkIdKeepsTheFirst() {
val row = stored(links = listOf(link(id = "l1", label = "First"), link(id = "l1", label = "Second")))
assertEquals("First", tree(row).link("l1").label)
}
@Test fun linksAppendAfterTheCodedRowsInCreationOrder() {
val row = stored(links = listOf(link(id = "a"), link(id = "b")))
val shape = tree(row).shape()
assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") })
assertEquals(Routes.page("about"), shape[shape.indexOf("link:a") - 1])
}
@Test fun aLinksOrderPlacesItAmongTheCodedRows() {
val row = stored(links = listOf(link(order = 0)))
assertEquals("link:l1", tree(row).shape().first())
}
@Test fun aLinkCanSitInsideASection() {
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore")),
links = listOf(link(section = "lore"), link(id = "top")),
)
val nodes = tree(row)
assertEquals(listOf(Routes.WIKI, "link:l1"), nodes.section("lore").items.shape())
assertTrue("link:top" in nodes.shape())
}
@Test fun aLinkNamingAnUnknownSectionStaysTopLevel() {
// Its destination is still good; only the grouping was wrong.
val row = stored(links = listOf(link(section = "nope")))
assertTrue("link:l1" in tree(row).shape())
}
// ── AC-3: the gates run after the build, and empty a section honestly ──
private val admin = Session.SignedIn(SessionUser(id = 1, username = "u", role = Role.ADMIN))
private fun prune(nodes: List<NavNode>, session: Session, features: ShardFeatures?) =
pruneNav(nodes) { isEntryVisible(it, session, features) }
@Test fun aSectionEmptiedByTheGatesIsDropped() {
// The case the rule exists for: a group whose every member is withheld by
// the shard's visibility config must not draw as a header over nothing.
val row = stored(
items = items("/site/market" to item(section = "lore")),
sections = listOf(section("lore")),
)
val pruned = prune(
tree(row),
admin,
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
)
assertTrue(pruned.filterIsInstance<NavNode.Section>().isEmpty())
}
@Test fun aSectionKeepsTheMembersThisCallerMaySee() {
val row = stored(
items = items(
"/site/market" to item(section = "lore"),
"/wiki" to item(section = "lore"),
),
sections = listOf(section("lore")),
)
val pruned = prune(
tree(row),
admin,
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
)
assertEquals(listOf(Routes.WIKI), pruned.section("lore").items.shape())
}
@Test fun anOverrideCannotUnhideAGatedRowByGroupingIt() {
// Relabelled, moved to the front, marked `hidden: false` and tucked into a
// section of its own — and still not shown, because the shard does not
// publish the market and an admin does not outrank that.
val row = stored(
items = items("/site/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")),
sections = listOf(section("lore", order = 0)),
)
val pruned = prune(
tree(row),
admin,
ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)),
)
assertTrue(pruned.filterIsInstance<NavNode.Section>().isEmpty())
assertTrue(pruned.none { it is NavNode.Item && it.entry.route == Routes.SHARD_MARKET })
}
@Test fun aSectionSurvivesOnALinkAlone() {
// Links carry no gate — the page behind one enforces its own access — so a
// section holding one is never emptied by the caller's role.
val row = stored(
items = items("/site/market" to item(section = "lore")),
sections = listOf(section("lore")),
links = listOf(link(section = "lore")),
)
val pruned = prune(tree(row), Session.SignedOut, ShardFeatures(level = "anonymous", visible = emptySet()))
assertEquals(listOf("link:l1"), pruned.section("lore").items.shape())
}
@Test fun theAppsOwnRowsAreStillGatedInTheTree() {
val row = stored(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore")),
)
val shape = prune(tree(row), Session.SignedOut, features = null).shape()
assertTrue(Routes.ACCOUNT !in shape)
assertTrue(Routes.ADMIN_DASHBOARD !in shape)
assertTrue(Routes.PLAYER_CHARACTERS !in shape)
assertTrue(Routes.CONTACT in shape)
}
@Test fun pruningAnUntouchedTreeIsTheCodedMenusVisibleEntries() {
// The two paths through the drawer have to agree: prune(tree) for a caller
// is exactly visibleEntries of the coded menu for that caller.
val features = ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS, ShardFeature.MARKET))
assertEquals(
visibleEntries(APP_MENU, admin, features).map { it.route },
prune(tree(null), admin, features).shape(),
)
}
}

View File

@@ -3,14 +3,18 @@
*/
package com.runicgateway.app.ui.player
import com.runicgateway.app.data.api.dto.CharPointsDto
import com.runicgateway.app.data.api.dto.CharProfileDto
import com.runicgateway.app.data.api.dto.EquipmentDto
import com.runicgateway.app.data.api.dto.TitlesDto
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Unit tests for the character-sheet display helpers (PLAN.md §6.3), mirroring the
* website's `CharacterSheet.jsx#displayTitles`: fame/karma + skill + a *literal*
* selected reward title, dropping bare cliloc numbers the app can't resolve.
* website's `CharacterSheet.jsx`: title selection over the server's cliloc-resolved
* parallel array, item naming precedence, and the Protocol 3.0 points block.
*/
class CharacterSheetHelpersTest {
@@ -51,4 +55,100 @@ class CharacterSheetHelpersTest {
val titles = TitlesDto(selected = 0, reward = listOf("The Great"), fameKarma = "The Great")
assertEquals(listOf("The Great"), displayTitles(titles))
}
// ── Cliloc-resolved titles (Protocol 3.0 §8.6) ───────────────────────
@Test fun displayTitlesPrefersTheServerResolvedRewardName() {
// The website resolves the numeric entries against its own cliloc table and
// sends a parallel array; the raw number is no longer the only thing we have.
val titles = TitlesDto(
selected = 0,
reward = listOf("1049565"),
rewardResolved = listOf("Knight of Trinsic"),
)
assertEquals(listOf("Knight of Trinsic"), displayTitles(titles))
}
@Test fun displayTitlesKeepsSelectedAlignedWhenAnEntryDoesNotResolve() {
// rewardResolved is POSITIONAL. An entry the table had nothing for is null and
// must be skipped WITHOUT shifting `selected` onto its neighbour — otherwise
// the sheet confidently shows the wrong title.
val titles = TitlesDto(
selected = 1,
reward = listOf("1049565", "1049566"),
rewardResolved = listOf(null, "Knight of Trinsic"),
)
assertEquals(listOf("Knight of Trinsic"), displayTitles(titles))
}
@Test fun displayTitlesFallsBackWhenTheSelectedTitleDidNotResolve() {
val titles = TitlesDto(
selected = 0,
reward = listOf("1049565", "1049566"),
rewardResolved = listOf(null, "Bane of Dragons"),
)
assertEquals(listOf("Bane of Dragons"), displayTitles(titles))
}
@Test fun displayTitlesStillSkipsNumbersWhenNothingResolved() {
// A shard that configures no cliloc table sends no rewardResolved at all —
// the pre-3.0 behavior, unchanged.
val titles = TitlesDto(selected = 0, reward = listOf("1049565"), rewardResolved = emptyList())
assertEquals(emptyList<String>(), displayTitles(titles))
}
// ── Equipment names ──────────────────────────────────────────────────
@Test fun itemLabelPrefersAPlayerGivenNameOverTheResolvedTypeName() {
// "Bob's lucky axe" must not be relabelled "hatchet".
val item = EquipmentDto(layer = "OneHanded", name = "Bob's lucky axe", clilocName = "hatchet")
assertEquals("Bob's lucky axe", item.label)
}
@Test fun itemLabelFallsBackThroughClilocNameThenLayer() {
assertEquals("hatchet", EquipmentDto(layer = "OneHanded", clilocName = "hatchet").label)
assertEquals("OneHanded", EquipmentDto(layer = "OneHanded").label)
assertNull(EquipmentDto().label)
}
// ── Loyalty & points (Protocol 3.0 §7.3) ─────────────────────────────
@Test fun pointsLabelUsesTheHumanisedKeyWhenTheNameIsACliloc() {
// The PRIMARY path on a real shard: most systems name themselves with a
// cliloc, so nameString comes back null.
assertEquals("Queens Loyalty", pointsLabel(CharPointsDto(system = "QueensLoyalty")))
assertEquals("Clean Up Britannia", pointsLabel(CharPointsDto(system = "CleanUpBritannia")))
assertEquals("Void Pool", pointsLabel(CharPointsDto(system = "VoidPool")))
}
@Test fun pointsLabelPrefersTheShardsOwnNameWhenItHasOne() {
val entry = CharPointsDto(system = "QueensLoyalty", nameString = "Queen's Loyalty")
assertEquals("Queen's Loyalty", pointsLabel(entry))
}
@Test fun anUncappedSystemReportsNoCap() {
// maxPoints 0 means UNCAPPED and is the common case — three of five live
// boards on a real shard. Nothing may divide by it.
assertNull(CharPointsDto(points = 900, maxPoints = 0).cap)
assertNull(CharPointsDto(points = 900, maxPoints = null).cap)
assertEquals(30000L, CharPointsDto(points = 900, maxPoints = 30000).cap)
}
@Test fun displayPointsDropsZeroesAndSortsByStandingDescending() {
val char = CharProfileDto(
points = listOf(
CharPointsDto(system = "A", points = 10),
CharPointsDto(system = "Zero", points = 0),
CharPointsDto(system = "B", points = 500),
CharPointsDto(system = "Null", points = null),
),
)
assertEquals(listOf("B", "A"), displayPoints(char).map { it.system })
}
@Test fun displayPointsIsEmptyForAProfileWithNoPointsBlock() {
// A pre-3.0 shard plugin sends none, and a new character has earned nothing —
// both render as nothing at all rather than an empty card.
assertEquals(emptyList<CharPointsDto>(), displayPoints(CharProfileDto()))
}
}

View File

@@ -12,6 +12,7 @@ import com.runicgateway.app.data.api.dto.PresenceDto
import com.runicgateway.app.data.api.dto.ShardStatusDto
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.api.fake.FakeShardStream
import com.runicgateway.app.data.repository.ShardFeaturesRepository
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
@@ -39,6 +40,10 @@ class ShardBoardViewModelTest {
private fun repo(stream: FakeShardStream = FakeShardStream()) = ShardRepository(api, stream, json)
// The hub reads the feature set only to filter its board tiles; these tests
// exercise loading, so the answer stays at its "unknown" default (show all).
private fun features() = ShardFeaturesRepository(api)
// ── Champs: snapshot + live upsert/remove ─────────────────────────────
@Test fun champsSeedsSnapshotAndMergesLiveFrames() {
api.champs = listOf(ChampDto(serial = "0x1", category = "champion", name = "Rikktor"))
@@ -108,7 +113,7 @@ class ShardBoardViewModelTest {
ShardStreamEvent.Frame("presence.online", buildJsonObject { put("count", 9) }),
),
)
val vm = ShardViewModel(repo(stream))
val vm = ShardViewModel(repo(stream), features())
val hub = (vm.state.value as UiState.Success).data
assertTrue(hub.status.isOnline)
// presence.online frame patched the count in place.
@@ -118,6 +123,6 @@ class ShardBoardViewModelTest {
@Test fun shardHubStatusErrorIsUiError() {
api.error = httpError(503)
assertTrue(ShardViewModel(repo()).state.value is UiState.Error)
assertTrue(ShardViewModel(repo(), features()).state.value is UiState.Error)
}
}

View File

@@ -0,0 +1,184 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasPlaceDto
import com.runicgateway.app.data.api.dto.AtlasSpawnerDto
import com.runicgateway.app.data.api.dto.MarketListingDto
import com.runicgateway.app.data.api.dto.MarketLocationDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
/**
* The pure display helpers behind the four Protocol 3.0 screens (PLAN.md §9 M11).
* Each one exists because the raw wire value would be wrong or misleading on screen —
* units in tenths, delays in seconds, an "uncapped" cap of zero, a gated field.
*/
class ShardContentHelpersTest {
// ── Rules (§5) ───────────────────────────────────────────────────────
@Test fun skillCapsConvertOutOfTenths() {
// 1000 is 100.0. Showing the raw number reads as a shard with ten times the
// usual limit, which is worse than showing nothing.
assertEquals("100", formatSkillCap(1000 / 10.0))
assertEquals("72.5", formatSkillCap(725 / 10.0))
}
@Test fun systemKeysHumanise() {
// Word boundaries become spaces and the inner capital is kept, matching the
// website's `humanise` — "City Loyalty" is the system's actual name.
assertEquals("City Loyalty", humaniseSystem("cityLoyalty"))
assertEquals("Vvv", humaniseSystem("vvv"))
assertEquals("Treasure Maps", humaniseSystem("treasureMaps"))
}
@Test fun theRestartScheduleOnlyShowsWhenTheShardRunsOne() {
assertEquals("04:30", formatRestart(enabled = true, hour = 4, minute = 30))
assertEquals("04:00", formatRestart(enabled = true, hour = 4, minute = null))
assertNull(formatRestart(enabled = false, hour = 4, minute = 30))
assertNull(formatRestart(enabled = true, hour = null, minute = 30))
}
// ── Leaderboards (§7) ────────────────────────────────────────────────
@Test fun boardLabelFallsBackToTheHumanisedKey() {
// The PRIMARY path: four of five boards on a real shard name themselves with a
// cliloc and send nameString null.
assertEquals("Queens Loyalty", boardLabel(PointsBoardDto(system = "QueensLoyalty")))
assertEquals(
"Queen's Loyalty",
boardLabel(PointsBoardDto(system = "QueensLoyalty", nameString = "Queen's Loyalty")),
)
assertEquals("Clean Up Britannia", boardLabel(PointsBoardDto(system = "CleanUpBritannia", nameString = " ")))
}
@Test fun anUncappedBoardReportsNoCap() {
assertNull(PointsBoardDto(maxPoints = 0).cap)
assertEquals(30000L, PointsBoardDto(maxPoints = 30000).cap)
}
@Test fun boardsOrderByContestedThenName() {
val boards = listOf(
PointsBoardDto(system = "Quiet", players = 2),
PointsBoardDto(system = "Busy", players = 900),
PointsBoardDto(system = "AlsoQuiet", players = 2),
)
assertEquals(listOf("Busy", "Also Quiet", "Quiet"), orderBoards(boards).map { boardLabel(it) })
}
@Test fun boardsTheShardHidesFromItsOwnGumpAreDropped() {
// `showOnGump` is the shard's own "is this player-facing?" signal.
val boards = listOf(
PointsBoardDto(system = "Shown", players = 1, showOnGump = true),
PointsBoardDto(system = "Internal", players = 99, showOnGump = false),
)
assertEquals(listOf("Shown"), orderBoards(boards).map { it.system })
}
// ── Market (§8) ──────────────────────────────────────────────────────
@Test fun listingTitlePrefersAPlayerNameThenTheResolvedOne() {
assertEquals("Bob's axe", listingTitle(MarketListingDto(name = "Bob's axe", displayName = "hatchet")))
assertEquals("hatchet", listingTitle(MarketListingDto(displayName = "hatchet")))
}
@Test fun listingTitleIsNullWithoutAnyName() {
// A shard with no cliloc table configured publishes neither, and the screen
// falls back to the item id rather than inventing a label.
assertNull(listingTitle(MarketListingDto(itemId = 3922)))
}
@Test fun aStackShowsItsCount() {
// "12 × ingot" and "ingot" at the same price are very different offers.
assertEquals("12 × ingot", listingTitle(MarketListingDto(displayName = "ingot", amount = 12)))
assertEquals("ingot", listingTitle(MarketListingDto(displayName = "ingot", amount = 1)))
assertEquals("ingot", listingTitle(MarketListingDto(displayName = "ingot", amount = null)))
}
@Test fun locationPrefersTheHouseThenTheRegion() {
assertEquals(
"Darrow's Tower, Felucca",
locationLine(MarketLocationDto(map = "Felucca", region = "Britain", house = "Darrow's Tower")),
)
assertEquals("Britain, Felucca", locationLine(MarketLocationDto(map = "Felucca", region = "Britain")))
assertEquals("Felucca", locationLine(MarketLocationDto(map = "Felucca")))
}
@Test fun aGatedLocationIsNullRatherThanAHalfAnswer() {
// The block is nested precisely so one admin rule takes the facet, the
// coordinates, the region and the house together — there is no partial state
// to render.
assertNull(locationLine(null))
assertNull(locationLine(MarketLocationDto(x = 100, y = 200)))
}
// ── Atlas (§6) ───────────────────────────────────────────────────────
@Test fun respawnDelaysAreReadAsSeconds() {
// The API normalises XmlSpawner's mixed minutes/seconds, so these ARE seconds.
assertEquals("30s", formatRespawn(30, 30))
assertEquals("5m", formatRespawn(300, 300))
assertEquals("5m10m", formatRespawn(300, 600))
assertEquals("1m 30s", formatRespawn(90, 90))
}
@Test fun aHalfSpecifiedRespawnStillReads() {
assertEquals("5m", formatRespawn(300, null))
assertEquals("5m", formatRespawn(null, 300))
assertNull(formatRespawn(null, null))
}
@Test fun spawnerPlacePrefersTheServersPlacementLabel() {
// The point-in-rect transform is the reason this feature exists: it turns
// "5411,1234" into "Despise, Felucca".
val spawner = AtlasSpawnerDto(
label = "Despise, Felucca",
region = "Despise",
facet = "Felucca",
x = 5411,
y = 1234,
)
assertEquals("Despise, Felucca", spawnerPlace(spawner))
}
@Test fun spawnerPlaceFallsBackThroughRegionLandmarkThenCoordinates() {
assertEquals(
"Despise, Felucca",
spawnerPlace(AtlasSpawnerDto(region = "Despise", facet = "Felucca")),
)
assertEquals(
"Yew Crossroads, Trammel",
spawnerPlace(AtlasSpawnerDto(landmark = "Yew Crossroads", facet = "Trammel")),
)
// ~17% of stock spawns resolve to no named place; coordinates are honest there.
assertEquals(
"Felucca 5411, 1234",
spawnerPlace(AtlasSpawnerDto(facet = "Felucca", x = 5411, y = 1234)),
)
}
@Test fun placeLabelUsesTheServersResolvedNameAndFallsBackToTheFacet() {
assertEquals(
"Isamu-Jima",
placeLabel(AtlasPlaceDto(facet = "Tokuno", label = "Isamu-Jima", spawners = 4)),
)
// The server already falls back to "Wilderness", so a label-less place is the
// degenerate case; the facet still says something, an empty row does not.
assertEquals("Tokuno", placeLabel(AtlasPlaceDto(facet = "Tokuno")))
assertEquals("Tokuno", placeLabel(AtlasPlaceDto(facet = "Tokuno", label = " ")))
}
@Test fun facetSummaryLeadsWithWhereItMostlyIs() {
val creature = AtlasCreatureDto(
slug = "lizardman",
facets = mapOf("Trammel" to 4, "Felucca" to 30, "Ilshenar" to 12),
)
assertEquals("Felucca, Ilshenar, Trammel", facetSummary(creature))
assertNull(facetSummary(AtlasCreatureDto(slug = "unique")))
}
}

View File

@@ -0,0 +1,223 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.shard
import com.runicgateway.app.core.net.ShardStreamEvent
import com.runicgateway.app.data.api.dto.AtlasCreatureDto
import com.runicgateway.app.data.api.dto.AtlasCreaturePageDto
import com.runicgateway.app.data.api.dto.MarketListingDto
import com.runicgateway.app.data.api.dto.MarketPageDto
import com.runicgateway.app.data.api.dto.MarketVendorDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
import com.runicgateway.app.data.api.dto.RulesetDto
import com.runicgateway.app.data.api.fake.FakePublicApi
import com.runicgateway.app.data.api.fake.FakeShardStream
import com.runicgateway.app.data.repository.ShardRepository
import com.runicgateway.app.ui.ErrorKind
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.util.MainDispatcherRule
import com.runicgateway.app.util.httpError
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
/**
* The four Protocol 3.0 content screens (PLAN.md §9 M11): loading, the live merge, and
* the states that are easy to get wrong — "published nothing" vs "switched off", and a
* gated feature reading as unavailable rather than as a fault.
*/
class ShardContentViewModelTest {
@get:Rule val mainDispatcherRule = MainDispatcherRule()
private val api = FakePublicApi()
private val json = Json { ignoreUnknownKeys = true; explicitNulls = false; coerceInputValues = true }
private fun repo(stream: FakeShardStream = FakeShardStream()) = ShardRepository(api, stream, json)
// ── Rules ────────────────────────────────────────────────────────────
@Test fun rulesLoadTheRuleset() {
api.ruleset = RulesetDto(shard = "UOMysticmoon", expansion = "EJ")
val state = RulesViewModel(repo()).state.value
assertEquals("UOMysticmoon", (state as UiState.Success).data?.shard)
}
@Test fun anUnpublishedRulesetIsASuccessWithNoBody() {
// Distinct from the feature being switched off: the shard is reachable and
// simply hasn't emitted world.ruleset yet.
api.ruleset = null
val state = RulesViewModel(repo()).state.value
assertTrue(state is UiState.Success)
assertNull((state as UiState.Success).data)
}
@Test fun aGatedRulesetFeatureReadsAsUnavailableNotAsAFault() {
api.error = httpError(404)
val state = RulesViewModel(repo()).state.value
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (state as UiState.Error).kind)
}
@Test fun aLiveRulesetFrameReplacesTheLoadedCopy() {
// The frame IS the whole ruleset — the shard re-emits it on every reconnect, so
// a restart with edited config updates an open screen.
api.ruleset = RulesetDto(shard = "Old", expansion = "EJ")
val stream = FakeShardStream(
listOf(
ShardStreamEvent.Frame(
"world.ruleset",
buildJsonObject { put("shard", "New"); put("expansion", "EJ") },
),
),
)
val state = RulesViewModel(repo(stream)).state.value
assertEquals("New", (state as UiState.Success).data?.shard)
}
// ── Leaderboards ─────────────────────────────────────────────────────
@Test fun leaderboardsSeedAndMergeLiveBoards() {
api.pointsBoards = listOf(
PointsBoardDto(system = "QueensLoyalty", players = 800),
PointsBoardDto(system = "VoidPool", players = 10),
)
val stream = FakeShardStream(
listOf(
ShardStreamEvent.Frame(
"points.board",
buildJsonObject { put("system", "VoidPool"); put("players", 999) },
),
),
)
val boards = (LeaderboardsViewModel(repo(stream)).state.value as UiState.Success).data
// The merged board overtook the seeded one on the contested ordering.
assertEquals(listOf("VoidPool", "QueensLoyalty"), boards.map { it.system })
}
@Test fun aGatedLeaderboardsFeatureReadsAsUnavailable() {
api.error = httpError(403)
val state = LeaderboardsViewModel(repo()).state.value
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (state as UiState.Error).kind)
}
// ── Market ───────────────────────────────────────────────────────────
@Test fun marketLoadsListingsAndMeta() {
api.market = MarketPageDto(
listings = listOf(MarketListingDto(serial = "0x1", displayName = "hatchet", price = 250)),
total = 1,
)
val vm = MarketViewModel(repo())
assertEquals(1, (vm.state.value as UiState.Success).data.listings.size)
}
@Test fun aBlankMarketQueryIsNotSentAsAnEmptyFilter() {
MarketViewModel(repo())
assertNull(api.lastMarketQuery)
}
@Test fun theMarketQueryIsBoundedToWhatTheServerAccepts() {
// Trimmed here rather than bounced as a 400.
val vm = MarketViewModel(repo())
vm.onQueryChange("x".repeat(200))
assertEquals(MarketViewModel.MAX_QUERY, vm.query.value.length)
}
@Test fun aFailedMetaLookupDoesNotBlankTheResults() {
// Meta drives the staleness banner and the filter options; it is secondary.
api.market = MarketPageDto(listings = listOf(MarketListingDto(serial = "0x1")), total = 1)
val vm = MarketViewModel(repo())
assertTrue(vm.state.value is UiState.Success)
}
@Test fun aVendorLoadsBySerialAndKeepsItForRetry() {
api.marketVendor = MarketVendorDto(serial = "0x40001234", shopName = "Darrow's Wares", truncated = true)
val vm = MarketVendorViewModel(repo())
vm.load("0x40001234")
val vendor = (vm.state.value as UiState.Success).data
assertEquals("Darrow's Wares", vendor.shopName)
assertTrue(vendor.truncated)
// Retry re-uses the serial rather than needing it passed again.
api.error = httpError(500)
vm.retry()
assertTrue(vm.state.value is UiState.Error)
}
// ── Atlas ────────────────────────────────────────────────────────────
@Test fun atlasLoadsCreaturesAndDiscoversTheShardsFacets() {
// Nothing may NAME a facet — a shard can add, replace or rename them, so the
// filter options come from the shard's own data.
api.atlasCreatures = AtlasCreaturePageDto(
creatures = listOf(
AtlasCreatureDto(slug = "lizardman", facets = mapOf("Felucca" to 30, "Sosaria" to 2)),
AtlasCreatureDto(slug = "orc", facets = mapOf("Underdark" to 5)),
),
total = 2,
)
val vm = AtlasViewModel(repo())
assertEquals(2, (vm.state.value as UiState.Success).data.creatures.size)
assertEquals(listOf("Felucca", "Sosaria", "Underdark"), vm.facets.value)
}
@Test fun aFilteredPageDoesNotNarrowTheFacetOptions() {
api.atlasCreatures = AtlasCreaturePageDto(
creatures = listOf(AtlasCreatureDto(slug = "a", facets = mapOf("Felucca" to 1, "Trammel" to 1))),
)
val vm = AtlasViewModel(repo())
assertEquals(listOf("Felucca", "Trammel"), vm.facets.value)
// Filtering to one facet must not leave the picker with only that option.
api.atlasCreatures = AtlasCreaturePageDto(
creatures = listOf(AtlasCreatureDto(slug = "a", facets = mapOf("Felucca" to 1))),
)
vm.onFacetChange("Felucca")
assertEquals(listOf("Felucca", "Trammel"), vm.facets.value)
assertEquals("Felucca", api.lastAtlasFacet)
}
@Test fun aGatedAtlasReadsAsUnavailable() {
api.error = httpError(404)
val state = AtlasViewModel(repo()).state.value
assertEquals(ErrorKind.FEATURE_UNAVAILABLE, (state as UiState.Error).kind)
}
@Test fun aCreatureLoadsBySlug() {
api.atlasCreature = AtlasCreatureDto(slug = "lizardman", name = "Lizardman", total = 214)
val vm = AtlasCreatureViewModel(repo())
vm.load("lizardman")
assertEquals(214, (vm.state.value as UiState.Success).data.total)
}
}

Some files were not shown because too many files have changed in this diff Show More