feat(m4): player self-service & game data
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m18s

Account self-service over the role-agnostic /auth/me/account* surface
(change username/password, TOTP enroll/disable, linked SSO identities),
game-account linking ([link one-time code + hybrid signup gated on the
public gameAccountSignup flag), and text-only own game data: per-account
character roster -> character sheet (attributes/vitals/resistances/skills/
equipment + guild/governor standing), player vendors + recent sales, and
own houses (decay/IDOC).

Adds three PLAYER-access menu groups (My Characters/Vendors/Houses)
revealed only when the session role is player, with a PlayerGate that
sends a signed-out or server-side-demoted user home. Each per-account
read carries its own load state, so a down shard (503) degrades that
account to offline/retry without blocking the rest (7).

Pure consumer of the existing bearer API -- no backend/protocol change.
17 new JVM unit tests cover the account + player-shard DTO decode (hex
serials, permissive objects, equipment mods) and the character-sheet
title/skill display helpers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
This commit is contained in:
2026-07-19 22:33:22 -05:00
committed by Claude
parent ca704caaaf
commit d4f7fcb241
25 changed files with 2546 additions and 34 deletions

View File

@@ -0,0 +1,64 @@
/*
* 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.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decoding tests for the self-service ("me") account DTOs (PLAN.md §6.3, §6.4).
* Shapes come from the website's `account.controller` handlers surfaced under
* `/auth/me/account*`; unknown keys are ignored (additive fields, §8).
*/
class AccountDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun accountDecodesSecurityFlags() {
val dto = json.decodeFromString<PlayerAccountDto>(
"""{"id":42,"username":"newplayer","role":"player","email":"p@example.com",
"status":"active","totp_enabled":false,"has_password":true}""",
)
assertEquals(42L, dto.id)
assertEquals("newplayer", dto.username)
assertTrue(dto.has_password)
assertFalse(dto.totp_enabled)
}
@Test fun ssoAccountHasNoPassword() {
// An SSO-provisioned account omits email and reports has_password=false.
val dto = json.decodeFromString<PlayerAccountDto>(
"""{"id":7,"username":"ssouser","role":"player","email":null,
"status":"active","totp_enabled":true,"has_password":false}""",
)
assertFalse(dto.has_password)
assertTrue(dto.totp_enabled)
}
@Test fun totpSetupDecodesQr() {
val dto = json.decodeFromString<TotpSetupDto>(
"""{"otpauthUrl":"otpauth://totp/Runic:admin?secret=ABC","qr":"data:image/png;base64,iVBORw0KGgo="}""",
)
assertTrue(dto.qr!!.startsWith("data:image/png;base64,"))
}
@Test fun totpStateDecodes() {
assertTrue(json.decodeFromString<TotpStateDto>("""{"totp_enabled":true}""").totp_enabled)
}
@Test fun linkedIdentityDecodes() {
val dto = json.decodeFromString<LinkedIdentityDto>(
"""{"provider":"discord","email":"u@example.com","linked_at":"2026-07-19T22:00:00Z"}""",
)
assertEquals("discord", dto.provider)
assertEquals("u@example.com", dto.email)
}
}

View File

@@ -0,0 +1,102 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.data.api.dto
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonPrimitive
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Decoding tests for a player's own game-data DTOs (PLAN.md §6.3). Shapes come from
* the website's `player/shard.controller.js` + `docs/link/INTEGRATION.md` §5. The
* roster / char / vendor reads are permissive objects, so the parser must ignore
* unknown keys (additive fields, §8) and tolerate hex-string serials.
*/
class PlayerShardDtoTest {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
coerceInputValues = true
}
@Test fun rosterDecodesCharsIncludingOffline() {
val dto = json.decodeFromString<RosterDto>(
"""{"kind":"account.roster","acct":"whitlocktech",
"chars":[{"slot":0,"serial":"0x24C","name":"Darrow","body":400,"online":false}]}""",
)
assertEquals("whitlocktech", dto.acct)
assertEquals(1, dto.chars.size)
assertEquals("0x24C", dto.chars[0].serial)
assertEquals("Darrow", dto.chars[0].name)
}
@Test fun charProfileDecodesStatsSkillsEquipmentAndTitles() {
val dto = json.decodeFromString<CharProfileDto>(
"""{"kind":"char.profile","serial":"0x24C","name":"Darrow","online":true,"acct":"wt",
"stats":{"str":100,"dex":90,"int":75,"hits":100,"hitsMax":110,
"resist":{"phys":70,"fire":68,"cold":60,"pois":55,"energy":50}},
"skills":[{"n":"Swordsmanship","base":100.0,"value":120.0,"cap":120.0},
{"n":"Anatomy","base":0.0,"value":0.0,"cap":100.0}],
"equipment":[{"serial":"0x40","layer":"OneHanded","itemId":5040,"hue":1153,
"mods":{"DamageIncrease":50,"HitLightning":30}}],
"titles":{"selected":0,"reward":["Knight"],"fameKarma":"The Great"},
"guild":{"name":"Knights","abbr":"KNT"},"governorOf":["Britain"]}""",
)
assertEquals("Darrow", dto.name)
assertTrue(dto.online)
assertEquals(100, dto.stats?.str)
assertEquals(70, dto.stats?.resist?.phys)
assertEquals(2, dto.skills.size)
assertEquals(120.0, dto.skills[0].value!!, 0.0)
val mods = dto.equipment[0].mods
assertNotNull(mods)
assertEquals("50", mods!!["DamageIncrease"]!!.jsonPrimitive.content)
assertEquals("KNT", dto.guild?.abbr)
assertEquals(listOf("Britain"), dto.governorOf)
}
@Test fun vendorSnapshotDecodesListings() {
val dto = json.decodeFromString<VendorSnapshotDto>(
"""{"kind":"vendor.snapshot","acct":"seed_000",
"vendors":[{"serial":"0x2C0","shopName":"Seed Shop","holdGold":24186,
"ownerSerial":"0x1F5","map":"Felucca","x":1402,"y":1604,
"listings":[{"serial":"0x4001440F","itemId":3937,"amount":1,"price":69819,"forSale":true}]}]}""",
)
assertEquals(1, dto.vendors.size)
assertEquals(24186L, dto.vendors[0].holdGold)
assertEquals(69819L, dto.vendors[0].listings[0].price)
assertTrue(dto.vendors[0].listings[0].forSale)
}
@Test fun vendorSaleDecodes() {
val dto = json.decodeFromString<VendorSaleDto>(
"""{"t":1783720195626,"itemType":"Longsword","amount":1,"price":100,
"commission":5,"ownerAcct":"whitlocktech"}""",
)
assertEquals(1783720195626L, dto.t)
assertEquals("Longsword", dto.itemType)
assertEquals(100L, dto.price)
}
@Test fun playerHouseDecodesHexSerialAndIdoc() {
val dto = json.decodeFromString<PlayerHouseDto>(
"""{"serial":"0x4004705F","stage":"IDOC","map":"Trammel","x":1,"y":2,"z":3,
"region":"Britain","name":"An Unnamed House","isIdoc":true,
"ownerAcct":"wt","updatedAt":"2026-07-19T22:00:00Z"}""",
)
assertEquals("0x4004705F", dto.serial)
assertEquals("IDOC", dto.stage)
assertTrue(dto.isIdoc)
}
@Test fun linkResultDecodes() {
val dto = json.decodeFromString<ShardLinkResultDto>("""{"linked":true,"account":"whitlocktech"}""")
assertTrue(dto.linked)
assertEquals("whitlocktech", dto.account)
}
}

View File

@@ -40,7 +40,7 @@ class MenuAccessTest {
@Test fun staffSeeAccountButNoPlayerOnlyGroups() {
val visible = routes(signedIn(Role.EDITOR))
assertTrue(visible.contains(Routes.ACCOUNT))
// No PLAYER-access entry leaks to staff (none exist yet in M3; guard the rule).
// No PLAYER-access entry (the M4 game-data groups) leaks to staff.
val playerOnly = APP_MENU.filter { it.access == MenuAccess.PLAYER }.map { it.route }
assertTrue(playerOnly.none { visible.contains(it) })
}

View File

@@ -0,0 +1,54 @@
/*
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.runicgateway.app.ui.player
import com.runicgateway.app.data.api.dto.TitlesDto
import org.junit.Assert.assertEquals
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.
*/
class CharacterSheetHelpersTest {
@Test fun formatSkillDropsTrailingZero() {
assertEquals("100", formatSkill(100.0))
assertEquals("85.3", formatSkill(85.3))
}
@Test fun displayTitlesEmptyForNull() {
assertEquals(emptyList<String>(), displayTitles(null))
}
@Test fun displayTitlesUsesFameKarmaSkillAndSelectedLiteralReward() {
val titles = TitlesDto(
selected = 1,
reward = listOf("1049565", "Slayer of Dragons"),
fameKarma = "The Great",
skill = "Grandmaster Swordsman",
)
assertEquals(
listOf("The Great", "Grandmaster Swordsman", "Slayer of Dragons"),
displayTitles(titles),
)
}
@Test fun displayTitlesSkipsNumericSelectedReward() {
// Selected points at a bare cliloc number → skipped (no cliloc table).
val titles = TitlesDto(selected = 0, reward = listOf("1049565"))
assertEquals(emptyList<String>(), displayTitles(titles))
}
@Test fun displayTitlesFallsBackToFirstLiteralWhenNoneSelected() {
val titles = TitlesDto(selected = -1, reward = listOf("1049565", "Champion"))
assertEquals(listOf("Champion"), displayTitles(titles))
}
@Test fun displayTitlesDeduplicates() {
val titles = TitlesDto(selected = 0, reward = listOf("The Great"), fameKarma = "The Great")
assertEquals(listOf("The Great"), displayTitles(titles))
}
}