feat(m4): player self-service & game data
All checks were successful
PR Checks / android-build (pull_request) Successful in 10m18s
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user