feat(content): roster uplift — PGR 12 cars/6 tracks/18 events, Echoes 24 arenas, 8 bowling lanes

- PGR: four cars per class (12), three more code-spline city circuits (6),
  six more career events (18) with unlock/reward graph integrity tests; menu
  event count is dynamic now.
- Echoes: 24 authored arenas (was 12), all corridor/crystal-validated for the
  radius-21 puck; existing indices unchanged.
- Bowling: 8 lanes/balls/rivals at the existing 1:1:1 proportion with a setup
  layout that fits; per-lane determinism tested.
- Labyrinth verified at device parity (112+4 mazes); documented.
- Echoes and PGR entry goldens re-recorded for the new counts; specs updated.
- Suite 1,598/0.
This commit is contained in:
John Smith
2026-09-12 18:23:38 -04:00
parent c5c1924c00
commit c675375c48
16 changed files with 567 additions and 43 deletions
@@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
@@ -413,34 +412,34 @@ private fun BowlingSetup(
text = "lane",
style = TextStyle(fontFamily = Selawik, fontSize = DoradoTokens.TYPE_CAPTION.sp, color = colors.textSecondary),
)
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
BowlingLane.entries.take(3).forEach { option ->
BowlingChip(option.label, option == lane) { onLane(option) }
BowlingLane.entries.chunked(3).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
row.forEach { option ->
BowlingChip(option.label, option == lane) { onLane(option) }
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
BowlingLane.entries.drop(3).forEach { option ->
BowlingChip(option.label, option == lane) { onLane(option) }
BasicText(
text = "ball",
style = TextStyle(fontFamily = Selawik, fontSize = DoradoTokens.TYPE_CAPTION.sp, color = colors.textSecondary),
)
BowlingBall.entries.chunked(4).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
row.forEach { option ->
BowlingChip(option.label, option == ball) { onBall(option) }
}
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (mode == BowlingMode.BLACKJACK) {
BasicText(
text = "ball",
text = "dealer",
style = TextStyle(fontFamily = Selawik, fontSize = DoradoTokens.TYPE_CAPTION.sp, color = colors.textSecondary),
)
Spacer(Modifier.width(4.dp))
BowlingBall.entries.forEach { option ->
BowlingChip(option.label, option == ball) { onBall(option) }
}
Spacer(Modifier.width(8.dp))
if (mode == BowlingMode.BLACKJACK) {
BasicText(
text = "dealer",
style = TextStyle(fontFamily = Selawik, fontSize = DoradoTokens.TYPE_CAPTION.sp, color = colors.textSecondary),
)
Spacer(Modifier.width(4.dp))
BowlingRival.entries.forEach { option ->
BowlingChip(option.label, option == rival) { onRival(option) }
BowlingRival.entries.chunked(4).forEach { row ->
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
row.forEach { option ->
BowlingChip(option.label, option == rival) { onRival(option) }
}
}
}
}
@@ -941,6 +940,17 @@ private class BowlingStage {
),
)
}
BowlingLane.TUNDRA -> for (z in listOf(-580f, -380f, -180f)) {
scene.add(nodeAt(postMesh, x = -28f, y = 12f, z = z, material = Material3d(shade(rgb(lane.glowRgb), 0.4f))))
scene.add(nodeAt(postMesh, x = 28f, y = 12f, z = z, material = Material3d(shade(rgb(lane.glowRgb), 0.4f))))
}
BowlingLane.FOUNDRY -> for (z in listOf(-560f, -340f, -140f)) {
scene.add(nodeAt(beamMesh, y = 24f, z = z, material = Material3d(shade(rgb(lane.glowRgb), 0.35f))))
}
BowlingLane.MONSOON -> for (z in listOf(-600f, -400f, -200f)) {
scene.add(nodeAt(starMesh, x = -25f, y = 3f, z = z, material = glow))
scene.add(nodeAt(starMesh, x = 25f, y = 3f, z = z, material = glow))
}
}
val shot = match.shot
@@ -86,7 +86,7 @@ enum class BowlingLength(val label: String, val frames: Int) {
}
/**
* Our five original lanes. [friction] is longitudinal deceleration (u/s^2),
* Our original lanes. [friction] is longitudinal deceleration (u/s^2),
* [hook] the lateral acceleration scale (u/s^2), [oilLength] the fraction of
* the lane before hook engages, [deck] the pin-deck restitution, and the rgb
* fields drive the original decor palette.
@@ -106,6 +106,9 @@ enum class BowlingLane(
DUNES("sunset dunes", 38f, 26f, 0.76f, 0.44f, 0x96613A, 0x39220F, 0xFF8B45),
GROTTO("tidal grotto", 24f, 64f, 0.48f, 0.56f, 0x1E4E48, 0x08211F, 0x5CF2C6),
ORBIT("stardust station", 14f, 104f, 0.26f, 0.70f, 0x2C2B40, 0x0F0F1A, 0xC9A0FF),
TUNDRA("frozen tundra", 34f, 40f, 0.70f, 0.48f, 0x5A6E82, 0x1C2630, 0xBFE3FF),
FOUNDRY("iron foundry", 20f, 78f, 0.40f, 0.60f, 0x4A2E22, 0x180C08, 0xFF6B3D),
MONSOON("monsoon alley", 26f, 58f, 0.54f, 0.58f, 0x264A5A, 0x0A1A24, 0x59D0E8),
}
/** Our own ball roster; [grip] scales the hook the timing meter can impart. */
@@ -115,6 +118,9 @@ enum class BowlingBall(val label: String, val rgb: Int, val grip: Float) {
JADE("jade", 0x2CB57E, 1.0f),
TIDE("tide", 0x3388E0, 0.95f),
LASER("laser", 0xC9A0FF, 1.05f),
ONYX("onyx", 0x2A2A2E, 0.85f),
AMBER("amber", 0xE0A030, 1.1f),
FROST("frost", 0x9FE8FF, 0.8f),
}
/** Original CPU rivals; [skill] 0..1 drives the throw distribution. */
@@ -124,6 +130,9 @@ enum class BowlingRival(val label: String, val skill: Float) {
MORROW("morrow", 0.63f),
KESTREL("kestrel", 0.73f),
VESPER("vesper", 0.82f),
ORION("orion", 0.50f),
CINDER("cinder", 0.68f),
HALCYON("halcyon", 0.90f),
}
/**
@@ -421,6 +421,246 @@ object EchoesArenas {
"##############################",
)
private val QUARRY = listOf(
"##############################",
"#............................#",
"#..............f.............#",
"#..##....##....##....##......#",
"#..##....##....##....##......#",
"#............................#",
"#.....*.....*.....*.....*....#",
"#............................#",
"#............................#",
"#..##....##....##....##......#",
"#..##....##....##....##......#",
"#............................#",
"#............................#",
"#.....*.....*.....*.....*....#",
"#..............s.............#",
"#............................#",
"##############################",
)
private val RAVINE = listOf(
"##############################",
"#............................#",
"#...*....................*...#",
"#.............f..............#",
"#............................#",
"#.###########.....#########..#",
"#............................#",
"#............................#",
"#............*...............#",
"#............................#",
"#..########.....###########..#",
"#............................#",
"#............................#",
"#.....*................*.....#",
"#..............s.............#",
"#............................#",
"##############################",
)
private val TERRACE = listOf(
"##############################",
"#............................#",
"#..*.........................#",
"#.....#.....#.....#.....#....#",
"#.....#.....#.....#.....#....#",
"#.....#.....#.....#.....#....#",
"#.....#.....#.....#.....#....#",
"#............................#",
"#........*..f..*.....*.....*.#",
"#............................#",
"#.....#.....#.....#.....#....#",
"#.....#.....#.....#.....#....#",
"#.....#.....#.....#.....#....#",
"#.....#.....#.....#.....#....#",
"#...s..........*.............#",
"#............................#",
"##############################",
)
private val PLAZA = listOf(
"##############################",
"#............................#",
"#..............*.............#",
"#...##................##.....#",
"#...##................##.....#",
"#............................#",
"#............####............#",
"#............####............#",
"#.......*....##r#....*.......#",
"#............####............#",
"#............................#",
"#...##................##.....#",
"#...##................##.....#",
"#............................#",
"#.....s........*.............#",
"#............................#",
"##############################",
)
private val WARREN = listOf(
"##############################",
"#............................#",
"#.s..........................#",
"#............................#",
"#...#...#...#...#...#...#....#",
"#............................#",
"#.....*...f...*.......*......#",
"#............................#",
"#...#...#...#...#...#...#....#",
"#............................#",
"#.....*.......*.......*......#",
"#............................#",
"#...#...#...#...#...#...#....#",
"#............................#",
"#..............*.............#",
"#............................#",
"##############################",
)
private val ATOLL = listOf(
"##############################",
"#............................#",
"#...*....................r...#",
"#............................#",
"#........############........#",
"#........#..........#........#",
"#........#..........#........#",
"#..............*.............#",
"#.............s..............#",
"#.............*..............#",
"#........#......*...#........#",
"#........#..........#........#",
"#........############........#",
"#............................#",
"#...f....................*...#",
"#............................#",
"##############################",
)
private val SWITCHBACK = listOf(
"##############################",
"#............................#",
"#.....*......................#",
"#............................#",
"#.############...............#",
"#............................#",
"#.....f...............*......#",
"#............................#",
"#.............##############.#",
"#............................#",
"#......*.....................#",
"#............................#",
"#.############...............#",
"#............................#",
"#..............s......*......#",
"#............................#",
"##############################",
)
private val CROSSROADS = listOf(
"##############################",
"#............................#",
"#............................#",
"#............####............#",
"#.......*....####....*.......#",
"#............................#",
"#............................#",
"#...########......########...#",
"#...########...r..########...#",
"#............................#",
"#............................#",
"#............####............#",
"#.......*....####....*.......#",
"#............................#",
"#..............s.............#",
"#............................#",
"##############################",
)
private val BASIN = listOf(
"##############################",
"#............................#",
"#............................#",
"#...###........*.......###...#",
"#...###................###...#",
"#...###................###...#",
"#...###........f.......###...#",
"#............................#",
"#.*............*...........*.#",
"#............................#",
"#...###................###...#",
"#...###................###...#",
"#...###................###...#",
"#............................#",
"#.s............*.............#",
"#............................#",
"##############################",
)
private val STEPS = listOf(
"##############################",
"#............................#",
"#.s............f.............#",
"#...##.....##.....##.....##..#",
"#...##.....##.....##.....##..#",
"#............................#",
"#......*......*......*.......#",
"#............................#",
"#............................#",
"#...##.....##.....##.....##..#",
"#...##.....##.....##.....##..#",
"#......*.............*.......#",
"#............................#",
"#...##.....##.....##.....##..#",
"#...##.....##..*..##.....##..#",
"#............................#",
"##############################",
)
private val GROTTO = listOf(
"##############################",
"#............................#",
"#..............*.............#",
"#.....##....##....##....##...#",
"#.....##....##....##....##...#",
"#............................#",
"#..*...........*...........*.#",
"#............................#",
"#............................#",
"#.....##................##...#",
"#.....##................##...#",
"#..............*.............#",
"#............................#",
"#...........##....##.........#",
"#..r........##.*..##......s..#",
"#............................#",
"##############################",
)
private val SUMMIT = listOf(
"##############################",
"#............................#",
"#...*..........*.........*...#",
"#............................#",
"#.....##..............##.....#",
"#.....##.......f......##.....#",
"#.....##......##......##.....#",
"#.............##.............#",
"#.............##.............#",
"#.....##......##......##.....#",
"#.....##......##......##.....#",
"#.....##..............##.....#",
"#............................#",
"#...*....................*...#",
"#..............s.............#",
"#............................#",
"##############################",
)
val ALL: List<EchoesArena> = listOf(
RawArena("drumlin", DRUMLIN),
RawArena("cairn", CAIRN),
@@ -434,6 +674,18 @@ object EchoesArenas {
RawArena("mesa", MESA),
RawArena("notch", NOTCH),
RawArena("oxbow", OXBOW),
RawArena("quarry", QUARRY),
RawArena("ravine", RAVINE),
RawArena("terrace", TERRACE),
RawArena("plaza", PLAZA),
RawArena("warren", WARREN),
RawArena("atoll", ATOLL),
RawArena("switchback", SWITCHBACK),
RawArena("crossroads", CROSSROADS),
RawArena("basin", BASIN),
RawArena("steps", STEPS),
RawArena("grotto", GROTTO),
RawArena("summit", SUMMIT),
).mapIndexed { index, raw -> parseArena(index, raw) }
fun byId(id: Int): EchoesArena = ALL[id.coerceIn(0, ALL.lastIndex)]
@@ -479,7 +479,7 @@ private fun PgrMenu(
)
Spacer(Modifier.height(6.dp))
PgrRow("quick race", "car · track · difficulty", onClick = onQuick)
PgrRow("career", "12 events · medals unlock the field", onClick = onCareer)
PgrRow("career", "${PgrEngine.PgrEvents.all.size} events · medals unlock the field", onClick = onCareer)
PgrRow("garage", "${profile.cars.size} cars owned", onClick = onGarage)
PgrRow("records", "lap, race and kudos bests", onClick = onRecords)
PgrRow("options", "tilt, auto-gas, sound", onClick = onOptions)
@@ -256,6 +256,24 @@ object PgrEngine {
trackWidth = 1.9f, areaFront = 0.8f, areaSide = 2.7f, brakeTorque = 7600f,
maxSteerDeg = 35f, colorsArgb = listOf(0x2F7A4E, 0xE8B93A, 0x2A2A2E, 0xD8D8D8),
),
PgrCarDef(
id = "lupo-v", name = "lupo v", classLevel = 1, creditCost = 22000, kudosCost = 0,
medalsRequired = 0, accel = 5, speedStat = 5, handling = 5, weight = 5, brake = 5,
massKg = 1195f, minTorque = 245f, maxTorque = 445f, lowRpm = 1120f, highRpm = 8300f,
gears = listOf(3.5f, 2.45f, 1.9f, 1.55f, 1.3f, 1.09f), reverseRatio = 3.25f,
finalDrive = 3.58f, radiusFront = 0.32f, radiusRear = 0.33f, wheelbase = 2.58f,
trackWidth = 1.88f, areaFront = 0.79f, areaSide = 2.64f, brakeTorque = 7400f,
maxSteerDeg = 34.5f, colorsArgb = listOf(0xD14B3C, 0x1E3E73, 0xF0E2C0, 0x28282C),
),
PgrCarDef(
id = "monte-r", name = "monte r", classLevel = 1, creditCost = 32000, kudosCost = 0,
medalsRequired = 1, accel = 5, speedStat = 5, handling = 6, weight = 5, brake = 5,
massKg = 1235f, minTorque = 252f, maxTorque = 458f, lowRpm = 1140f, highRpm = 8450f,
gears = listOf(3.55f, 2.48f, 1.91f, 1.53f, 1.26f), reverseRatio = 3.3f,
finalDrive = 3.62f, radiusFront = 0.32f, radiusRear = 0.34f, wheelbase = 2.62f,
trackWidth = 1.9f, areaFront = 0.8f, areaSide = 2.68f, brakeTorque = 7700f,
maxSteerDeg = 35.5f, colorsArgb = listOf(0x2E6E8E, 0xE0B23A, 0x30302F, 0xE8E8E8),
),
PgrCarDef(
id = "tigre-gts", name = "tigre gts", classLevel = 2, creditCost = 30000, kudosCost = 800,
medalsRequired = 3, accel = 6, speedStat = 6, handling = 6, weight = 6, brake = 6,
@@ -274,6 +292,24 @@ object PgrEngine {
trackWidth = 1.96f, areaFront = 0.82f, areaSide = 2.9f, brakeTorque = 8600f,
maxSteerDeg = 36f, colorsArgb = listOf(0xE07B2A, 0x2B2B30, 0x5A6B7A, 0xEFEFEF),
),
PgrCarDef(
id = "tempesta-7", name = "tempesta 7", classLevel = 2, creditCost = 60000, kudosCost = 2200,
medalsRequired = 7, accel = 7, speedStat = 7, handling = 6, weight = 6, brake = 7,
massKg = 1345f, minTorque = 315f, maxTorque = 575f, lowRpm = 1220f, highRpm = 8700f,
gears = listOf(3.52f, 2.48f, 1.9f, 1.55f, 1.31f, 1.09f), reverseRatio = 3.38f,
finalDrive = 3.68f, radiusFront = 0.33f, radiusRear = 0.34f, wheelbase = 2.66f,
trackWidth = 1.95f, areaFront = 0.83f, areaSide = 2.87f, brakeTorque = 8400f,
maxSteerDeg = 35f, colorsArgb = listOf(0xB02A2A, 0x2A2E36, 0xE8C24A, 0xF0F0F0),
),
PgrCarDef(
id = "vortex-s", name = "vortex s", classLevel = 2, creditCost = 72000, kudosCost = 2800,
medalsRequired = 8, accel = 7, speedStat = 7, handling = 7, weight = 6, brake = 7,
massKg = 1315f, minTorque = 330f, maxTorque = 590f, lowRpm = 1240f, highRpm = 8800f,
gears = listOf(3.56f, 2.52f, 1.93f, 1.57f, 1.32f, 1.1f), reverseRatio = 3.42f,
finalDrive = 3.72f, radiusFront = 0.33f, radiusRear = 0.35f, wheelbase = 2.69f,
trackWidth = 1.97f, areaFront = 0.85f, areaSide = 2.92f, brakeTorque = 8800f,
maxSteerDeg = 36.5f, colorsArgb = listOf(0x1E7A5A, 0x101820, 0xC8C8CE, 0xE04B2A),
),
PgrCarDef(
id = "aquila-sc", name = "aquila sc", classLevel = 3, creditCost = 80000, kudosCost = 3000,
medalsRequired = 9, accel = 8, speedStat = 8, handling = 8, weight = 7, brake = 8,
@@ -292,6 +328,24 @@ object PgrEngine {
trackWidth = 2.0f, areaFront = 0.9f, areaSide = 3.1f, brakeTorque = 9600f,
maxSteerDeg = 38f, colorsArgb = listOf(0xC8A02E, 0x101014, 0x27443A, 0xD8D8D8),
),
PgrCarDef(
id = "corsa-nera", name = "corsa nera", classLevel = 3, creditCost = 160000, kudosCost = 9000,
medalsRequired = 14, accel = 9, speedStat = 9, handling = 9, weight = 8, brake = 9,
massKg = 1220f, minTorque = 380f, maxTorque = 700f, lowRpm = 1320f, highRpm = 9100f,
gears = listOf(3.62f, 2.58f, 1.96f, 1.59f, 1.34f, 1.11f), reverseRatio = 3.48f,
finalDrive = 3.78f, radiusFront = 0.34f, radiusRear = 0.35f, wheelbase = 2.71f,
trackWidth = 1.99f, areaFront = 0.89f, areaSide = 3.05f, brakeTorque = 9400f,
maxSteerDeg = 37.5f, colorsArgb = listOf(0x141418, 0xC8A02E, 0x8E1C1C, 0xE8E8E8),
),
PgrCarDef(
id = "aurora-one", name = "aurora one", classLevel = 3, creditCost = 200000, kudosCost = 12000,
medalsRequired = 16, accel = 10, speedStat = 10, handling = 9, weight = 8, brake = 10,
massKg = 1195f, minTorque = 410f, maxTorque = 750f, lowRpm = 1350f, highRpm = 9300f,
gears = listOf(3.68f, 2.62f, 2.0f, 1.62f, 1.36f, 1.13f), reverseRatio = 3.52f,
finalDrive = 3.82f, radiusFront = 0.34f, radiusRear = 0.36f, wheelbase = 2.74f,
trackWidth = 2.02f, areaFront = 0.91f, areaSide = 3.12f, brakeTorque = 10000f,
maxSteerDeg = 38.5f, colorsArgb = listOf(0xF0F0F0, 0x1A2A4A, 0x2FA08E, 0xE0B23A),
),
)
val starter: PgrCarDef get() = all.first()
@@ -905,6 +959,42 @@ object PgrEngine {
width = 9f,
propSeed = 2026497481,
),
PgrTrackDef(
id = "westminster-ring",
name = "westminster ring",
city = PgrCity.LONDON,
controlPoints = listOf(
PgrVec2(0f, 0f), PgrVec2(110f, -35f), PgrVec2(210f, -5f), PgrVec2(260f, 80f),
PgrVec2(230f, 170f), PgrVec2(140f, 215f), PgrVec2(45f, 200f), PgrVec2(-30f, 140f),
PgrVec2(-40f, 55f),
),
width = 12f,
propSeed = 1118736411,
),
PgrTrackDef(
id = "shibuya-cross",
name = "shibuya cross",
city = PgrCity.TOKYO,
controlPoints = listOf(
PgrVec2(0f, 0f), PgrVec2(90f, -40f), PgrVec2(185f, -25f), PgrVec2(240f, 40f),
PgrVec2(215f, 110f), PgrVec2(250f, 175f), PgrVec2(175f, 225f), PgrVec2(90f, 195f),
PgrVec2(35f, 235f), PgrVec2(-35f, 190f), PgrVec2(-45f, 95f), PgrVec2(-15f, 25f),
),
width = 10f,
propSeed = 1338092767,
),
PgrTrackDef(
id = "midtown-dash",
name = "midtown dash",
city = PgrCity.NEW_YORK,
controlPoints = listOf(
PgrVec2(0f, 0f), PgrVec2(70f, -20f), PgrVec2(150f, -15f), PgrVec2(200f, 25f),
PgrVec2(230f, 90f), PgrVec2(185f, 150f), PgrVec2(120f, 170f), PgrVec2(60f, 150f),
PgrVec2(20f, 185f), PgrVec2(-30f, 150f), PgrVec2(-40f, 75f), PgrVec2(-25f, 25f),
),
width = 11f,
propSeed = 1717562309,
),
)
val all: List<PgrTrack> by lazy { defs.map { build(it) } }
@@ -1100,6 +1190,43 @@ object PgrEngine {
prerequisites = listOf("ev10", "ev11"), parMs = 340_000L,
rewardCarId = "falco-stradale", blurb = "six laps for the falco stradale.",
),
PgrEventDef(
id = "ev13", name = "westminster cup", type = PgrEventType.STREET_RACE,
trackId = "westminster-ring", laps = 3, difficulty = PgrDifficulty.HARD,
prerequisites = listOf("ev09"), parMs = 270_000L,
blurb = "the capital's new ring. hold the racing line to the flag.",
),
PgrEventDef(
id = "ev14", name = "shibuya drift", type = PgrEventType.KUDOS_CHALLENGE,
trackId = "shibuya-cross", laps = 3, difficulty = PgrDifficulty.HARD,
prerequisites = listOf("ev13"), targetKudos = 3000, parMs = 280_000L,
rewardCarId = "tempesta-7", blurb = "chain three thousand kudos for the tempesta 7.",
),
PgrEventDef(
id = "ev15", name = "midtown overtake", type = PgrEventType.OVERTAKE,
trackId = "midtown-dash", laps = 3, difficulty = PgrDifficulty.HARD,
prerequisites = listOf("ev13"), targetOvertakes = 6, parMs = 280_000L,
rewardCarId = "vortex-s", blurb = "six clean passes unlock the vortex s.",
),
PgrEventDef(
id = "ev16", name = "elite time trial", type = PgrEventType.TIME_VS_KUDOS,
trackId = "westminster-ring", laps = 4, difficulty = PgrDifficulty.HARD,
prerequisites = listOf("ev14", "ev15"), limitMs = 240_000L, targetKudos = 2500,
parMs = 240_000L, rewardCarId = "corsa-nera",
blurb = "beat the clock with kudos in hand for the corsa nera.",
),
PgrEventDef(
id = "ev17", name = "grand masters", type = PgrEventType.ONE_ON_ONE,
trackId = "shibuya-cross", laps = 4, difficulty = PgrDifficulty.HARD,
prerequisites = listOf("ev16"), parMs = 300_000L,
rewardCarId = "aurora-one", blurb = "one rival, four laps, the aurora one.",
),
PgrEventDef(
id = "ev18", name = "final showdown", type = PgrEventType.STREET_RACE,
trackId = "midtown-dash", laps = MAX_LAPS, difficulty = PgrDifficulty.HARD,
prerequisites = listOf("ev16", "ev17"), parMs = 360_000L,
blurb = "the last race on the streets of new york. first place only.",
),
)
fun byId(id: String): PgrEventDef? = all.firstOrNull { it.id == id }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

@@ -378,16 +378,54 @@ class BowlingTest {
}
@Test
fun `five original lanes carry distinct physics parameters`() {
assertEquals(5, BowlingLane.entries.size)
assertEquals(5, BowlingBall.entries.size)
assertEquals(5, BowlingRival.entries.size)
fun `eight original lanes carry distinct physics parameters`() {
assertEquals(8, BowlingLane.entries.size)
assertEquals(8, BowlingBall.entries.size)
assertEquals(8, BowlingRival.entries.size)
for (lane in BowlingLane.entries) {
assertTrue(lane.friction > 0f)
assertTrue(lane.hook > 0f)
assertTrue(lane.oilLength in 0f..1f)
assertTrue(lane.deck in 0f..1f)
assertTrue(lane.surfaceRgb != lane.backRgb)
}
assertEquals(8, BowlingLane.entries.map { it.label }.toSet().size)
assertEquals(8, BowlingBall.entries.map { it.label }.toSet().size)
assertEquals(8, BowlingRival.entries.map { it.label }.toSet().size)
assertEquals(8, BowlingBall.entries.map { it.rgb }.toSet().size)
for (ball in BowlingBall.entries) assertTrue(ball.grip > 0f)
for (rival in BowlingRival.entries) assertTrue(rival.skill in 0f..1f)
val pairs = BowlingLane.entries.map { it.friction to it.hook }.toSet()
assertEquals(8, pairs.size)
}
@Test
fun `every lane and ball runs a deterministic valid shot`() {
for (lane in BowlingLane.entries) {
val a = BowlingEngine.simulateShot(lane, List(BOWLING_NUM_PINS) { true }, straight, BowlingBall.COMET, seed = 77)
val b = BowlingEngine.simulateShot(lane, List(BOWLING_NUM_PINS) { true }, straight, BowlingBall.COMET, seed = 77)
assertEquals(lane.label, a, b)
assertTrue("${lane.label} never settled", a.done)
assertEquals(BOWLING_NUM_PINS, a.pins.size)
assertTrue(a.felledCount() in 0..BOWLING_NUM_PINS)
assertFalse(a.ball.x.isNaN())
}
for (ball in BowlingBall.entries) {
val shot = BowlingEngine.simulateShot(BowlingLane.PINERY, List(BOWLING_NUM_PINS) { true }, straight, ball, seed = 78)
assertTrue("${ball.label} never settled", shot.done)
}
}
@Test
fun `every rival throws deterministically and in range`() {
for (rival in BowlingRival.entries) {
val a = BowlingEngine.cpuThrow(rival, seed = 9, index = 0)
val b = BowlingEngine.cpuThrow(rival, seed = 9, index = 0)
assertEquals(rival.label, a, b)
assertTrue(a.speed in 0.35f..0.95f)
assertTrue(a.aim in -1f..1f)
assertTrue(a.spin in -1f..1f)
}
assertEquals(5, BowlingLane.entries.map { it.label }.toSet().size)
}
/* ------------------------------ match flow ------------------------------ */
@@ -369,7 +369,8 @@ class EchoesTest {
@Test
fun `all authored arenas parse with equal rows and floor spawns`() {
assertEquals(12, EchoesArenas.ALL.size)
assertEquals(24, EchoesArenas.ALL.size)
assertEquals(24, EchoesArenas.ALL.map { it.name }.toSet().size)
for (arena in EchoesArenas.ALL) {
assertEquals(30, arena.cols)
assertEquals(17, arena.rows)
@@ -380,6 +381,15 @@ class EchoesTest {
}
}
@Test
fun `arena lookup covers the whole campaign and clamps out of range`() {
for (index in EchoesArenas.ALL.indices) {
assertEquals(index, EchoesArenas.byId(index).id)
}
assertEquals(0, EchoesArenas.byId(-1).id)
assertEquals(EchoesArenas.ALL.lastIndex, EchoesArenas.byId(9_999).id)
}
@Test
fun `every crystal is reachable by a radius twenty one puck`() {
for (arena in EchoesArenas.ALL) {
@@ -618,4 +618,78 @@ class PgrTest {
repeat(PgrEngine.TICS_PER_SECOND) { race = PgrEngine.tickRace(race, null) }
assertTrue(race.phase == PgrEngine.PgrPhase.RACING)
}
// ------------------------------------------------------------------
// Content roster (device parity counts)
// ------------------------------------------------------------------
@Test
fun `content roster meets the device counts`() {
assertEquals(12, PgrEngine.PgrCars.all.size)
assertEquals(6, PgrEngine.PgrTracks.all.size)
assertEquals(18, PgrEngine.PgrEvents.all.size)
assertEquals(4, PgrEngine.PgrCars.all.count { it.classLevel == 1 })
assertEquals(4, PgrEngine.PgrCars.all.count { it.classLevel == 2 })
assertEquals(4, PgrEngine.PgrCars.all.count { it.classLevel == 3 })
assertEquals(
PgrEngine.PgrCars.starter,
PgrEngine.PgrCars.all.first(),
)
}
@Test
fun `every authored car, track and event id is unique`() {
val carIds = PgrEngine.PgrCars.all.map { it.id }
assertEquals(carIds.size, carIds.toSet().size)
val trackIds = PgrEngine.PgrTracks.all.map { it.def.id }
assertEquals(trackIds.size, trackIds.toSet().size)
val eventIds = PgrEngine.PgrEvents.all.map { it.id }
assertEquals(eventIds.size, eventIds.toSet().size)
}
@Test
fun `every car is unlockable through purchase or a career reward`() {
val events = PgrEngine.PgrEvents.all
val rewardCars = events.mapNotNull { it.rewardCarId }.toSet()
for (car in PgrEngine.PgrCars.all) {
val starter = car.id == PgrEngine.PgrCars.starter.id
val reward = car.id in rewardCars
val purchasable = car.creditCost > 0 && car.medalsRequired <= events.size
assertTrue("car ${car.id} is not unlockable", starter || reward || purchasable)
if (!starter) assertTrue("car ${car.id} costs nothing", car.creditCost > 0)
assertTrue("car ${car.id} medal gate exceeds events", car.medalsRequired <= events.size)
assertTrue("car ${car.id} kudos cost is negative", car.kudosCost >= 0)
}
}
@Test
fun `every event references valid content and the unlock graph is acyclic`() {
val events = PgrEngine.PgrEvents.all
val ids = events.map { it.id }.toSet()
val tracks = PgrEngine.PgrTracks.all.map { it.def.id }.toSet()
val cars = PgrEngine.PgrCars.all.map { it.id }.toSet()
for (event in events) {
assertTrue("event ${event.id} has unknown track", event.trackId in tracks)
event.rewardCarId?.let {
assertTrue("event ${event.id} rewards unknown car $it", it in cars)
}
event.prerequisites.forEach {
assertTrue("event ${event.id} prerequisite $it unknown", it in ids)
}
assertTrue("event ${event.id} lap count", event.laps in 1..PgrEngine.MAX_LAPS)
}
val reachable = HashSet<String>()
var changed = true
while (changed) {
changed = false
for (event in events) {
if (event.id in reachable) continue
if (event.prerequisites.all { it in reachable }) {
reachable += event.id
changed = true
}
}
}
assertEquals("career graph must be reachable from its roots", ids, reachable)
}
}
+5 -3
View File
@@ -105,9 +105,11 @@ as our own tile layouts.
- Tests (`app/src/test/java/com/heretek/dorado_hd/`): snapshot replay
determinism, collision/hit/invulnerability windows, power-up durations,
mode win/continue rules, unlock ordering, achievement thresholds.
- Fidelity target: full-parity mechanics with original arenas. Risks:
echo-replay determinism across frame rates, the volume of arena content,
and multiplayer over the modern stack (scope to local/co-op first).
- Fidelity target: full-parity mechanics with original arenas. The
Dorado-HD roster is closed at 24 original arenas (authored wall plans
validated for the 21-unit puck radius). Risks: echo-replay determinism
across frame rates, and multiplayer over the modern stack (scope to
local/co-op first).
## 7. Citation log
+4 -4
View File
@@ -105,10 +105,10 @@ synthesized SFX.
and tunneling guards at max speed, checkpoint rewind, pickup-open-goal
rule, par-star math, act unlock, save/restore, tilt-to-acceleration
mapping incl. landscape flip and 1.05 assist.
- Fidelity target: full-parity mechanics; ship 5 acts of original mazes
(target 116 levels, phased authoring). Risks: collision robustness at
750 px/s, tilt feel/calibration across devices, and maze authoring
volume.
- Fidelity target: full-parity mechanics; the Dorado-HD roster ships five
acts of original mazes — 116 levels (112 scenes + 4 bonus), at parity with
the device counts. Risks: collision robustness at 750 px/s, tilt
feel/calibration across devices, and maze authoring volume.
## 7. Citation log
+4 -3
View File
@@ -124,9 +124,10 @@ synthesized music/SFX/voices (no Microsoft assets).
classifier clamps, deterministic pin simulation replay, unlock matrix,
CPU turn order.
- Fidelity target: full-parity mechanics with original content; the
campaign ships the 5 original lane themes and a smaller character roster
first. Risks: physics tuning for believable pin action, replay camera
parity, and the animation/voiceover asset count.
Dorado-HD campaign ships 8 original lane themes (each with its own
friction/hook/oil parameter set) plus 8 balls and 8 CPU rivals. Risks:
physics tuning for believable pin action, replay camera parity, and the
animation/voiceover asset count.
## 7. Citation log
+4 -3
View File
@@ -122,9 +122,10 @@ and SFX, and our own ghost data.
values and combo stash, event win conditions, ghost record/playback,
credit/unlock rules.
- Fidelity target: full-parity racing sim mechanics with original cars,
cities and events (smaller initial car/event roster). Risks: physics
feel parity, AI quality, and the content volume of four cars per class
plus multi-segment cities.
cities and events. The Dorado-HD roster is closed at the device content
counts: 12 cars (four per class), 6 code-defined tracks (two per city) and
18 career events. Risks: physics feel parity, AI quality, and track
authoring volume.
## 7. Citation log