feat(rust): a Rust server's next wipe (module-rust phase 16, M18) #52

Merged
whitlocktech merged 1 commits from feat/rust-phase-16-next-wipe into edge 2026-09-25 19:18:16 +00:00
7 changed files with 118 additions and 0 deletions

View File

@@ -68,6 +68,24 @@ data class RustServerDto(
/** When this module last wrote the row — a failed poll moves it too. */
val updatedAt: String? = null,
val stale: Boolean = false,
/**
* When the server wipes next (module-rust phase 16, D130), or null. Null
* both when the operator set no schedule and against a module older than
* phase 16, which does not send the field — either way nothing is drawn.
*/
val nextWipe: RustNextWipeDto? = null,
)
/**
* The next wipe, as the module computed it from the operator's schedule on this
* read. [source] is `forced` (Facepunch's monthly forced wipe), `rule` (the
* server's own weekly or biweekly day) or `once` (a one-off date the operator
* set — a delayed or an extra wipe). An unknown word is shown like `rule`.
*/
@Serializable
data class RustNextWipeDto(
val at: String? = null,
val source: String? = null,
)
/**

View File

@@ -67,6 +67,29 @@ fun wipeDay(
.format(at.atZone(zone))
}
/**
* The next wipe (module-rust phase 16), as `Thu 1 Oct, 19:00 · in 6 days` in
* the PHONE's zone — or null when there is no instant to show.
*
* The module computes the instant from the operator's rule in the operator's
* zone; the phone only says it the reader's way, which is the same split the
* website's `nextWipe()` makes. The `(rescheduled)` mark for a one-off date is
* the caller's, from a string resource.
*/
fun nextWipeWhen(
value: String?,
now: Instant = Instant.now(),
zone: ZoneId = ZoneId.systemDefault(),
locale: Locale = Locale.getDefault(),
): String? {
val at = parseWireInstant(value) ?: return null
val local = at.atZone(zone)
val date = DateTimeFormatter.ofPattern("EEE d MMM", locale).format(local)
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT).withLocale(locale).format(local)
val distance = rustAgo(value, now) ?: return "$date, $time"
return "$date, $time · $distance"
}
/**
* "3 minutes ago", for a "last reported" line.
*

View File

@@ -186,6 +186,15 @@ private fun ServerHeader(
)
}
nextWipeLine(server)?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
Row(
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),

View File

@@ -142,6 +142,15 @@ private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) {
)
}
nextWipeLine(server)?.let {
Text(
text = it,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 2.dp),
)
}
Text(
text = lastReported(server),
style = MaterialTheme.typography.bodySmall,
@@ -170,6 +179,22 @@ internal fun describeWorld(server: RustServerDto): String? {
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
}
/**
* "Next wipe Thu 1 Oct, 19:00 · in 6 days", with "(rescheduled)" for a one-off
* date — or null, and the caller draws no line, when the server has no schedule
* or the module predates phase 16 and sends no field.
*/
@Composable
internal fun nextWipeLine(server: RustServerDto): String? {
val next = server.nextWipe ?: return null
val whenText = nextWipeWhen(next.at) ?: return null
return if (next.source == "once") {
stringResource(R.string.rust_next_wipe_rescheduled, whenText)
} else {
stringResource(R.string.rust_next_wipe, whenText)
}
}
/**
* "last reported 3 minutes ago".
*

View File

@@ -578,6 +578,8 @@
<string name="rust_world_size">size %1$d</string>
<string name="rust_world_seed">seed %1$d</string>
<string name="rust_wiped_on">wiped %1$s</string>
<string name="rust_next_wipe">Next wipe %1$s</string>
<string name="rust_next_wipe_rescheduled">Next wipe %1$s (rescheduled)</string>
<string name="rust_refresh_failed">Could not refresh just now. This is the last thing the site heard.</string>
<string name="rust_no_such_server">No such server</string>
<string name="rust_no_such_server_detail">This address does not name a server this site follows.</string>

View File

@@ -102,4 +102,23 @@ class RustDtoTest {
assertNull(event.str("name"))
assertEquals(FeedTone.SERVER, describe(event).tone)
}
// ── The next wipe (module-rust phase 16, M18) ──────────────────────────
@Test
fun `the next wipe decodes, and is null against a module that does not send it`() {
val list = json.decodeFromString<RustServerListDto>(
"""{"servers":[
{"id":"a","name":"A","nextWipe":{"at":"2026-10-01T18:00:00.000Z","source":"forced"}},
{"id":"b","name":"B","nextWipe":null},
{"id":"c","name":"C"}
]}""",
)
val (a, b, c) = list.servers
assertEquals("2026-10-01T18:00:00.000Z", a.nextWipe?.at)
assertEquals("forced", a.nextWipe?.source)
// No schedule, and a module older than phase 16: the same answer, nothing drawn.
assertNull(b.nextWipe)
assertNull(c.nextWipe)
}
}

View File

@@ -131,4 +131,26 @@ class RustFormatTest {
assertEquals("1234", shortSteamId("1234"))
assertEquals("", shortSteamId(null))
}
// ── The next wipe (module-rust phase 16, M18) ──────────────────────────
@Test
fun `the next wipe is said in the phone's zone with how far away it is`() {
val sept25 = Instant.parse("2026-09-25T12:00:00Z")
// The forced wipe: 18:00 UTC is 19:00 in London in summer, 13:00 in Chicago.
assertEquals(
"Thu 1 Oct, 19:00 · in 6 days",
nextWipeWhen("2026-10-01T18:00:00.000Z", sept25, ZoneId.of("Europe/London"), uk),
)
assertTrue(
nextWipeWhen("2026-10-01T18:00:00.000Z", sept25, ZoneId.of("America/Chicago"), Locale.US)!!
.startsWith("Thu 1 Oct, 1:00"),
)
}
@Test
fun `no instant is no line`() {
assertNull(nextWipeWhen(null, now, utc, uk))
assertNull(nextWipeWhen("soon", now, utc, uk))
}
}