diff --git a/games/bcook/meltdown/.gitignore b/games/bcook/meltdown/.gitignore new file mode 100644 index 0000000..9dbc33d --- /dev/null +++ b/games/bcook/meltdown/.gitignore @@ -0,0 +1,6 @@ +*.wasm +*.out +# native dev binary (`go build` drops it, named after the module dir) +/meltdown +# smoke output (`go run . -smoke` / `shellcade-kit smoke`) +/smoke-out diff --git a/games/bcook/meltdown/LICENSE b/games/bcook/meltdown/LICENSE new file mode 100644 index 0000000..fc7e2e0 --- /dev/null +++ b/games/bcook/meltdown/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Brandon Cook + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/games/bcook/meltdown/README.md b/games/bcook/meltdown/README.md new file mode 100644 index 0000000..5e0dd79 --- /dev/null +++ b/games/bcook/meltdown/README.md @@ -0,0 +1,135 @@ +# Meltdown ☢ + +A co-op reactor-room panic for the terminal, built on the +[shellcade/kit](https://github.com/shellcade/kit) developer kit and playable +over SSH at [shellcade.com](https://shellcade.com). + +The whole 80×24 screen is a failing reactor ship: rooms and corridors drawn in +box characters around a glowing core. You and your crew (one engineer per +player) run the halls putting out faults that keep erupting at the stations and +**get worse the longer you ignore them**. Every unfixed fault eats the core's +shared integrity, and faults spawn faster and faster — so the run *always* ends. +Your score is how long the crew survives. + +``` + CORE 100% 0s +┌─────────────────────────┬──────────────────────────┬─────────────────────────┐ +│ │ │ │ +│ · ☺ · · · · · ≈ · │ +│ │ +│ │ │ │ +├────────────· ───────────┼──────────── ·────────────┼──────────── ·───────────┤ +│ │ │ │ +│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │ +│ · · · ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓· ▲ · · │ +│ │ ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓ │ │ +│ │ │ ☺ │ +├──────────── ───────────┼──────────── ────────────┼──────────── ───────────┤ +│ ≈ · · │ Φ · · │ · · · │ +│ │ │ │ +│ · Φ · · · · · · · │ +└─────────────────────────┴──────────────────────────┴─────────────────────────┘ + LEAK [████████░░░░░░░░] MASH SPACE + ↑↓←→/hjkl move SPACE patch/hold type valve keys Q leave FAULTS 5 +``` + +The `▓` block at the centre is the reactor core — it **pulses, and its glow +shifts from hot yellow toward a dim cold red as integrity falls**. The glow *is* +the shared health bar. Each engineer (`☺`, your own drawn reverse-video so you +can find yourself) runs the bays fixing whatever erupts. + +## Controls + +| Key | Action | +|---------------------------|---------------------------------------------------| +| `↑ ↓ ← →` (or `k j h l`) | Walk one cell | +| `Space` | Patch a leak (mash) / smother a fire (hold) | +| letter keys | Crack a jammed valve — type the shown sequence | +| `Q` / `Esc` | Leave the reactor | + +## The faults + +| Glyph | Fault | How to fix it | +|-------|---------------|--------------------------------------------------------------------| +| `≈` | **Leak** | Stand on it and **mash `Space`** to patch it shut. | +| `▲` | **Fire** | Stand on or next to it and **hold `Space`** — let go early and it regrows. | +| `Φ` | **Jammed valve** | Stand on it and **type the shown 3–4 key sequence**; a wrong key re-jams it. | +| `◊` | **Breach** | Takes **two crew standing on it at once** — only ever spawns with a crew of 2+. | + +While you're on (or beside) a fault, a labelled **progress bar** shows your fix +filling up, plus a hint of what to press. Walk away from a half-fixed fire and +it grows back; leave any fault unattended and it keeps draining the core, biting +harder the longer it festers. + +## Solo & crew + +**Solo is first-class.** A lone engineer plays the exact same loop with the +fault cadence scaled down to one pair of hands, and two-person breaches never +spawn. As more crew join, the spawn rate rises **sub-linearly** (it scales with +the square root of crew size), so the work per person *falls* — a bigger crew is +an easier shift. Recruiting friends is the whole incentive. + +When the core finally hits zero the reactor melts down, and an end screen shows +the crew's survival time and each member's fix count. Best survival time (in +seconds) rides the arcade leaderboard. + +## Develop + +This is a standard Go program in the inner loop — no wasm, no network, no setup. + +```sh +go run . # play it in your terminal +go run . -seats 3 # hot-seat 3 engineers (Ctrl-T switches the active seat) +go run . -heartbeat 33ms # snappier frame rate +go test ./... # logic tests (each fault's fix, core damage, scaling, scoring) +``` + +`go test -run TestSnapshot -v` prints a plain-text snapshot of a composed frame, +handy for eyeballing layout changes. + +## Build the arcade artifact + +The arcade runs a sandboxed WebAssembly build. With +[TinyGo](https://tinygo.org) installed: + +```sh +tinygo build -opt=1 -no-debug -gc=leaking \ + -o meltdown.wasm -target wasip1 -buildmode=c-shared . +``` + +Then verify, play, and smoke it via the kit CLI: + +```sh +shellcade-kit check meltdown.wasm +shellcade-kit play meltdown.wasm --seats 3 +shellcade-kit smoke . # runs smoke.yaml, writes the preview shots +``` + +## How it works + +The game implements the kit `Game` + `Handler` contract — callbacks the arcade +invokes one at a time: + +- `OnStart` builds the reactor floor plan and schedules the first fault. +- `OnJoin` / `OnLeave` add and remove engineers (state is keyed by `AccountID` + so it survives room hibernation) and load/persist the durable best time. +- `OnInput` walks an engineer one cell, mashes a leak, or taps a valve key. Fire + and breach **holds** are derived from terminal auto-repeat via the kit + `keyhold` helper (terminals have no key-up event). +- `OnWake` is the ~20 Hz heartbeat: it advances the time-based fixes, drains the + core by every active fault, erupts new faults on the accelerating schedule, + ends the run when the core dies, and renders a per-engineer frame. + +| File | Responsibility | +|---------------|-------------------------------------------------------------| +| `main.go` | Game metadata + room factory + native entrypoint | +| `exports.go` | The eight ABI export trampolines (wasm build only) | +| `types.go` | Geometry, fault/crew structs, tuning constants | +| `room.go` | Handler callbacks, ship layout, movement, faults, spawning | +| `render.go` | Composing the 80×24 frame (reactor, core, faults, HUD) | + +## Future ideas + +A *saboteur* mode — one crew member secretly worsening faults instead of fixing +them — is an obvious extension, deliberately left out of v1 to keep the co-op +loop tight. diff --git a/games/bcook/meltdown/exports.go b/games/bcook/meltdown/exports.go new file mode 100644 index 0000000..faed7b4 --- /dev/null +++ b/games/bcook/meltdown/exports.go @@ -0,0 +1,33 @@ +//go:build wasip1 || tinygo.wasm + +package main + +import kit "github.com/shellcade/kit/v2" + +func init() { kit.Run(Game{}) } + +// The eight ABI exports, trampolined to the kit SDK. + +//go:export shellcade_abi +func expABI() int32 { return kit.ExportABI() } + +//go:export meta +func expMeta() int32 { return kit.ExportMeta() } + +//go:export start +func expStart() int32 { return kit.ExportStart() } + +//go:export join +func expJoin() int32 { return kit.ExportJoin() } + +//go:export leave +func expLeave() int32 { return kit.ExportLeave() } + +//go:export input +func expInput() int32 { return kit.ExportInput() } + +//go:export wake +func expWake() int32 { return kit.ExportWake() } + +//go:export close +func expClose() int32 { return kit.ExportClose() } diff --git a/games/bcook/meltdown/go.mod b/games/bcook/meltdown/go.mod new file mode 100644 index 0000000..bceb41d --- /dev/null +++ b/games/bcook/meltdown/go.mod @@ -0,0 +1,12 @@ +module shellcade.games/bcook/meltdown + +go 1.25.0 + +require github.com/shellcade/kit/v2 v2.11.0 + +require ( + github.com/extism/go-pdk v1.1.3 // indirect + golang.org/x/sys v0.44.0 // indirect + golang.org/x/term v0.43.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/games/bcook/meltdown/go.sum b/games/bcook/meltdown/go.sum new file mode 100644 index 0000000..80444d1 --- /dev/null +++ b/games/bcook/meltdown/go.sum @@ -0,0 +1,12 @@ +github.com/extism/go-pdk v1.1.3 h1:hfViMPWrqjN6u67cIYRALZTZLk/enSPpNKa+rZ9X2SQ= +github.com/extism/go-pdk v1.1.3/go.mod h1:Gz+LIU/YCKnKXhgge8yo5Yu1F/lbv7KtKFkiCSzW/P4= +github.com/shellcade/kit/v2 v2.11.0 h1:JCdxEn7hgspkhQGsPJCGiJxm8sNBxdp4k/ujyNl8lgw= +github.com/shellcade/kit/v2 v2.11.0/go.mod h1:6OXhYu2vu468CiBcO8ps8DVlGdvOdAFGr3ZK4Z2VNgk= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/games/bcook/meltdown/main.go b/games/bcook/meltdown/main.go new file mode 100644 index 0000000..67f027f --- /dev/null +++ b/games/bcook/meltdown/main.go @@ -0,0 +1,61 @@ +// Meltdown — a co-op reactor-room panic for shellcade. +// +// The whole 80x24 terminal is a failing reactor ship: rooms and corridors +// drawn in box characters around a glowing core. One crew member per player +// runs the halls (arrows / hjkl) putting out faults that erupt at the +// stations and worsen if neglected — LEAKs to patch, FIREs to smother, +// JAMMED VALVEs to key open, and (with a full crew) BREACHes that take two +// bodies at once. Every unfixed fault eats the core's shared integrity, and +// faults spawn faster and faster, so the run ALWAYS ends. Your score is how +// long the crew survives. +// +// Solo is first-class: the same loop, faults scaled down for one engineer and +// no two-person faults. More crew raises the spawn rate only sub-linearly, so +// a bigger crew is an easier shift — bring friends. +// +// Dev loop: go run . (plays in this terminal; -seats N for hot-seat) +// +// Artifact: tinygo build -opt=1 -no-debug -gc=leaking \ +// -o meltdown.wasm -target wasip1 -buildmode=c-shared . +package main + +import kit "github.com/shellcade/kit/v2" + +func main() { kit.Main(Game{}) } + +// Game is the module registry entry: static metadata plus the room factory. +type Game struct{} + +// Meta returns the static game metadata shown in the arcade. The bare slug +// "meltdown" is namespaced to "bcook/meltdown" by the platform from the +// catalog path; this Meta carries the bare name only. +func (Game) Meta() kit.GameMeta { + return kit.GameMeta{ + Slug: "meltdown", + Name: "Meltdown", + ShortDescription: "Keep a failing reactor alive: patch leaks, smother fires, and crack jammed valves before the core dies.", + MinPlayers: 1, + MaxPlayers: 6, + Tags: []string{"co-op", "action", "party", "survival"}, + + // Player characters: each crew member's tile renders beside their name + // on the roster and as their body in the reactor, visible to everyone. + CtxFeatures: kit.CtxFeatCharacter, + + QuickModeLabel: "Crew shift", + SoloModeLabel: "Lone engineer", + PrivateInviteLine: "Friends join your reactor crew when they enter the code.", + + Leaderboard: &kit.LeaderboardSpec{ + MetricLabel: "Survival", + Direction: kit.HigherBetter, + Aggregation: kit.BestResult, + Format: kit.Integer, + }, + } +} + +// NewRoom returns the per-room behavior. +func (Game) NewRoom(cfg kit.RoomConfig, svc kit.Services) kit.Handler { + return newRoom(cfg, svc) +} diff --git a/games/bcook/meltdown/meltdown_leaderboard_test.go b/games/bcook/meltdown/meltdown_leaderboard_test.go new file mode 100644 index 0000000..3074b64 --- /dev/null +++ b/games/bcook/meltdown/meltdown_leaderboard_test.go @@ -0,0 +1,146 @@ +package main + +import ( + "testing" + "time" + + kit "github.com/shellcade/kit/v2" +) + +// lastPostFor returns the most recent posted result whose first ranking is for +// the given account id, or false if none. +func lastPostFor(posted []kit.Result, accountID string) (kit.PlayerResult, bool) { + for i := len(posted) - 1; i >= 0; i-- { + for _, pr := range posted[i].Rankings { + if pr.Player.AccountID == accountID { + return pr, true + } + } + } + return kit.PlayerResult{}, false +} + +// countPostsFor returns how many posted results reference the account id. +func countPostsFor(posted []kit.Result, accountID string) int { + n := 0 + for _, res := range posted { + for _, pr := range res.Rankings { + if pr.Player.AccountID == accountID { + n++ + } + } + } + return n +} + +// TestSurvivalPostedLive asserts that as the reactor survives, a live result is +// posted to the leaderboard (StatusFinished) carrying the survival seconds. +func TestSurvivalPostedLive(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + + // Advance the reactor a few seconds; each wake should record the growing + // survival metric live. + for i := 0; i < 100 && rm.phase == phaseRunning; i++ { + tr.Advance(50 * time.Millisecond) + rm.OnWake(tr) + } + + pr, ok := lastPostFor(tr.Posted, a.AccountID) + if !ok { + t.Fatal("survival should be posted to the leaderboard during play") + } + if pr.Status != kit.StatusFinished { + t.Fatalf("live survival post should be StatusFinished, got %v", pr.Status) + } + if pr.Metric < 1 { + t.Fatalf("posted survival metric should reflect elapsed seconds, got %d", pr.Metric) + } +} + +// TestSurvivalPostedOnLeave asserts that a crew member who disconnects mid-run +// has their current survival posted with StatusDNF. +func TestSurvivalPostedOnLeave(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + + // Survive a while, then disconnect. + for i := 0; i < 60 && rm.phase == phaseRunning; i++ { + tr.Advance(50 * time.Millisecond) + rm.OnWake(tr) + } + want := int(rm.survivedSeconds()) + if want < 1 { + t.Fatalf("need a non-trivial run before leaving, got %d", want) + } + + rm.OnLeave(tr, a) + + pr, ok := lastPostFor(tr.Posted, a.AccountID) + if !ok { + t.Fatal("leaving mid-run should post the crew member's survival") + } + if pr.Status != kit.StatusDNF { + t.Fatalf("disconnect post should be StatusDNF, got %v", pr.Status) + } + if pr.Metric < want { + t.Fatalf("disconnect should post current survival %d, got %d", want, pr.Metric) + } +} + +// TestPeriodicFlushOnInterval asserts an abandoned-but-ticking reactor keeps +// recording: with crew present, FlushAll posts on a throttled interval, and not +// before that interval has elapsed. +func TestPeriodicFlushOnInterval(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + + // Pull the flush deadline half a second off the whole-second grid so the + // boundary (lastFlush + flushInterval) lands mid-second relative to + // startedAt. The boundary-crossing wake below is then within a single + // survived-second, so it cannot also produce an OnImprove live post — any new + // post must be the periodic flush. + rm.lastFlush = rm.lastFlush.Add(500 * time.Millisecond) + + // One initial wake to seed live tracking / the first post. + tr.Advance(50 * time.Millisecond) + rm.OnWake(tr) + + // Tick in small steps until we sit just shy of the flush interval — within + // the same survived-second as where we'll cross the boundary. Stop as soon as + // the very next sub-second step would cross flushInterval. + for rm.phase == phaseRunning { + if rm.now.Add(50*time.Millisecond).Sub(rm.lastFlush) >= flushInterval { + break + } + tr.Advance(50 * time.Millisecond) + rm.OnWake(tr) + } + + // Snapshot right before crossing: the post count and the current whole + // survived-second. The boundary-crossing wake below is a sub-second step, so + // the survived second must not change across it — meaning OnImprove can NOT + // produce a new live post. Any new post is therefore the periodic flush. + beforeSecs := int(rm.survivedSeconds()) + beforeCount := countPostsFor(tr.Posted, a.AccountID) + + tr.Advance(50 * time.Millisecond) + rm.OnWake(tr) + + if got := int(rm.survivedSeconds()); got != beforeSecs { + t.Fatalf("test setup: survived second changed across the boundary wake (%d -> %d)", beforeSecs, got) + } + afterCount := countPostsFor(tr.Posted, a.AccountID) + if afterCount <= beforeCount { + t.Fatalf("a periodic flush should post once the interval elapses, with no live improvement (before %d, after %d)", beforeCount, afterCount) + } + + // The flush post carries StatusFinished. + pr, _ := lastPostFor(tr.Posted, a.AccountID) + if pr.Status != kit.StatusFinished { + t.Fatalf("periodic flush post should be StatusFinished, got %v", pr.Status) + } +} diff --git a/games/bcook/meltdown/render.go b/games/bcook/meltdown/render.go new file mode 100644 index 0000000..57defc6 --- /dev/null +++ b/games/bcook/meltdown/render.go @@ -0,0 +1,376 @@ +package main + +import ( + "fmt" + "math" + + kit "github.com/shellcade/kit/v2" +) + +// render composes and sends a tailored frame to every connected crew member, +// reusing one long-lived frame buffer (Send copies, so steady-state rendering +// is allocation-free regardless of crew size). +func (rm *room) render(r kit.Room) { + for _, p := range r.Members() { + rm.frame.Clear() + rm.composeFor(rm.frame, p) + r.Send(p, rm.frame) + } +} + +func (rm *room) composeFor(f *kit.Frame, viewer kit.Player) { + rm.drawShip(f) + rm.drawCore(f) + rm.drawFaults(f) + rm.drawCrew(f, viewer) + rm.drawHUD(f, viewer) + if rm.phase == phaseOver { + rm.drawEndScreen(f, viewer) + } else { + rm.drawFixPanels(f, viewer) + } +} + +// drawShip renders the hull and bulkheads. Walls use light box characters; a +// station with no active fault shows a dim marker so crew can read the layout. +func (rm *room) drawShip(f *kit.Frame) { + for r := 0; r < interiorRows; r++ { + for c := 0; c < cols; c++ { + row := top + r + switch rm.grid[r][c] { + case cellWall: + f.SetRune(row, c, rm.wallGlyph(r, c), kit.Style{FG: wallColor}) + case cellStation: + if rm.faultAt(r, c) == nil { + f.SetRune(row, c, '·', kit.Style{FG: kit.Gray(0x55)}) + } + } + } + } +} + +// wallGlyph picks a box-drawing character for a wall cell from its walled +// neighbours, so the hull and bulkheads read as connected piping. +func (rm *room) wallGlyph(r, c int) rune { + isWall := func(rr, cc int) bool { + if rr < 0 || rr >= interiorRows || cc < 0 || cc >= cols { + return false + } + return rm.grid[rr][cc] == cellWall + } + up := isWall(r-1, c) + down := isWall(r+1, c) + left := isWall(r, c-1) + right := isWall(r, c+1) + switch { + case up && down && left && right: + return '┼' + case up && down && right: + return '├' + case up && down && left: + return '┤' + case left && right && down: + return '┬' + case left && right && up: + return '┴' + case down && right: + return '┌' + case down && left: + return '┐' + case up && right: + return '└' + case up && left: + return '┘' + case up || down: + return '│' + default: + return '─' + } +} + +// drawCore renders the central reactor core. It pulses, and its colour shifts +// from hot yellow toward a dim cold red as integrity falls — the glow IS the +// shared health bar. +func (rm *room) drawCore(f *kit.Frame) { + frac := rm.core / coreMax + // Colour ramp by integrity. + var col kit.Color + switch { + case frac > 0.6: + col = coreHot + case frac > 0.3: + col = coreWarm + default: + col = coreCold + } + // Pulse: bright cells alternate with the glyph each ~half second; a dying + // core pulses faster (more frantic). + period := 0.5 - 0.3*(1-frac) + if period < 0.12 { + period = 0.12 + } + bright := math.Mod(float64(rm.now.UnixNano())/1e9, period*2) < period + glyph := '▓' + if !bright { + glyph = '▒' + } + if frac <= 0.3 && !bright { + glyph = '░' + } + attr := kit.Attr(0) + if bright && frac > 0.3 { + attr = kit.AttrBold + } + for r := 0; r < interiorRows; r++ { + for c := 0; c < cols; c++ { + if rm.grid[r][c] == cellCore { + f.SetRune(top+r, c, glyph, kit.Style{FG: col, Attr: attr}) + } + } + } +} + +// drawFaults renders each active hazard at its station, tinted by type and +// pulsing while it festers. +func (rm *room) drawFaults(f *kit.Frame) { + blink := rm.now.UnixNano()/int64(250*1e6)%2 == 0 + for _, fl := range rm.faults { + st := kit.Style{FG: faultColor[fl.kind], Attr: kit.AttrBold} + if blink { + st.Attr |= kit.AttrReverse + } + f.SetRune(top+fl.row, fl.col, faultGlyph[fl.kind], st) + } +} + +// drawCrew renders each crew member's body; the viewer's own body is +// reverse-video so they can find themselves at a glance. +func (rm *room) drawCrew(f *kit.Frame, viewer kit.Player) { + for id, m := range rm.crew { + if !m.joined { + continue + } + st := kit.Style{FG: m.color, Attr: kit.AttrBold} + if id == viewer.AccountID { + st.Attr |= kit.AttrReverse + } + f.SetRune(top+m.row, m.col, m.glyph, st) + } +} + +// --- HUD --------------------------------------------------------------------- + +func (rm *room) drawHUD(f *kit.Frame, viewer kit.Player) { + frac := rm.core / coreMax + + // Top-left: crew roster in join order, each in their colour with fix count. + col := 1 + for _, id := range rm.order { + m := rm.crew[id] + p := rm.names[id] + if m == nil { + continue + } + name := p.Handle + if len([]rune(name)) > 7 { + name = string([]rune(name)[:7]) + } + fg := m.color + if !m.joined { + fg = kit.DimGray + } + f.Set(0, col, kit.CharacterCell(p.Character)) // character tile + col += 2 + seg := fmt.Sprintf("%s %d", name, m.fixes) + if id == viewer.AccountID { + seg += "*" + } + col = f.Text(0, col, seg+" ", kit.Style{FG: fg}) + if col > 44 { + break + } + } + + // Top-right: the core integrity readout. At low integrity a klaxon banner + // flashes over it. + if frac <= 0.25 && rm.phase == phaseRunning && rm.now.UnixNano()/int64(300*1e6)%2 == 0 { + f.TextRight(0, cols-1, "!! ALARM !! CORE CRITICAL", + kit.Style{FG: kit.Red, Attr: kit.AttrBold | kit.AttrReverse}) + } else { + f.TextRight(0, cols-1, fmt.Sprintf("CORE %3d%% %ds", int(frac*100+0.5), + int(rm.survivedSeconds())), rm.coreReadoutStyle(frac)) + } + + // Bottom: controls + status. + f.Text(bottom+1, 1, "↑↓←→/hjkl move SPACE patch/hold type valve keys Q leave", + kit.Style{FG: kit.DimGray}) + f.TextRight(bottom+1, cols-1, fmt.Sprintf("FAULTS %d", len(rm.faults)), + kit.Style{FG: kit.Gray(0x99)}) +} + +func (rm *room) coreReadoutStyle(frac float64) kit.Style { + switch { + case frac > 0.6: + return kit.Style{FG: kit.Green, Attr: kit.AttrBold} + case frac > 0.3: + return kit.Style{FG: kit.Yellow, Attr: kit.AttrBold} + default: + return kit.Style{FG: kit.Red, Attr: kit.AttrBold} + } +} + +// drawFixPanels shows a progress bar for the fault the viewer is currently +// working, plus a hint of what to do. Only the viewer's own active fault is +// shown so the panel doesn't clutter for spectators. +func (rm *room) drawFixPanels(f *kit.Frame, viewer kit.Player) { + m := rm.crew[viewer.AccountID] + if m == nil || !m.joined { + return + } + // Find the fault this crew member can work right now. + var working *fault + for _, fl := range rm.faults { + if rm.adjacentToFault(m, fl) { + working = fl + break + } + } + if working == nil { + return + } + rm.drawFixBar(f, working, m) +} + +// drawFixBar paints a one-line progress panel near the bottom for the fault the +// crew member is working. +func (rm *room) drawFixBar(f *kit.Frame, fl *fault, m *crewMember) { + row := bottom // just above the controls line + color := faultColor[fl.kind] + label := faultName[fl.kind] + + var hint string + switch fl.kind { + case faultLeak: + hint = "MASH SPACE" + case faultFire: + hint = "HOLD SPACE" + case faultValve: + hint = "TYPE: " + rm.valveHint(fl) + case faultBreach: + if fl.holders >= 2 { + hint = "HOLD — both crew on it" + } else { + hint = "NEEDS 2 CREW" + } + } + + const barW = 16 + filled := int(fl.progress*float64(barW) + 0.5) + if filled > barW { + filled = barW + } + bar := make([]rune, barW) + for i := range bar { + if i < filled { + bar[i] = '█' + } else { + bar[i] = '░' + } + } + text := fmt.Sprintf(" %s [%s] %s ", label, string(bar), hint) + startCol := (cols - len([]rune(text))) / 2 + if startCol < 1 { + startCol = 1 + } + f.Text(row, startCol, text, kit.Style{FG: color, Attr: kit.AttrBold}) +} + +// valveHint renders the valve sequence with the keys already matched dimmed +// out, so the crew member sees what to press next. +func (rm *room) valveHint(fl *fault) string { + var b []rune + for i, ru := range fl.seq { + if i < fl.seqAt { + b = append(b, '·') + } else { + b = append(b, ru) + } + } + return string(b) +} + +// drawEndScreen overlays the meltdown summary: total survival time and each +// crew member's fix count. +func (rm *room) drawEndScreen(f *kit.Frame, viewer kit.Player) { + box := []string{} + title := "*** CORE MELTDOWN ***" + box = append(box, title) + box = append(box, "") + secs := int(rm.survivedSeconds()) + box = append(box, fmt.Sprintf("The crew held the reactor for %d seconds.", secs)) + if vm := rm.crew[viewer.AccountID]; vm != nil { + best := vm.best + if secs > best { + best = secs + } + box = append(box, fmt.Sprintf("Your best shift: %d seconds.", best)) + } + box = append(box, "") + // Per-member fix tally in join order. + if len(rm.order) > 0 { + box = append(box, "— crew —") + for _, id := range rm.order { + m := rm.crew[id] + p := rm.names[id] + if m == nil { + continue + } + box = append(box, fmt.Sprintf("%-10s %d fixes", trimName(p.Handle, 10), m.fixes)) + } + box = append(box, "") + } + box = append(box, "The shift is over — your time rides the leaderboard.") + + // Center the panel, clearing a backdrop so it reads as a clean modal over + // the dead reactor. + h := len(box) + startRow := (kit.Rows-h)/2 - 1 + if startRow < 1 { + startRow = 1 + } + widest := 0 + for _, line := range box { + if n := len([]rune(line)); n > widest { + widest = n + } + } + pad := 2 + bc0 := (cols-widest)/2 - pad + bc1 := bc0 + widest + 2*pad - 1 + blank := kit.Cell{Rune: ' ', BG: kit.RGB(0x10, 0x06, 0x06)} + f.Fill(startRow-1, bc0, startRow+h, bc1, blank) + for i, line := range box { + rw := []rune(line) + startCol := (cols - len(rw)) / 2 + if startCol < 0 { + startCol = 0 + } + bg := kit.RGB(0x10, 0x06, 0x06) + st := kit.Style{FG: kit.White, BG: bg} + switch { + case i == 0: + st = kit.Style{FG: kit.Red, BG: bg, Attr: kit.AttrBold} + case i == 2: + st = kit.Style{FG: kit.Yellow, BG: bg, Attr: kit.AttrBold} + } + f.Text(startRow+i, startCol, line, st) + } +} + +func trimName(s string, n int) string { + rs := []rune(s) + if len(rs) > n { + return string(rs[:n]) + } + return s +} diff --git a/games/bcook/meltdown/room.go b/games/bcook/meltdown/room.go new file mode 100644 index 0000000..ef6dd9c --- /dev/null +++ b/games/bcook/meltdown/room.go @@ -0,0 +1,666 @@ +package main + +import ( + "context" + "math" + "strconv" + "strings" + "time" + + kit "github.com/shellcade/kit/v2" + "github.com/shellcade/kit/v2/keyhold" +) + +// room is the live game state. Per-crew state lives in crew, keyed by account +// id (hibernation-safe); the reactor grid, faults, and core are shared. +type room struct { + kit.Base + cfg kit.RoomConfig + svc kit.Services + + grid [interiorRows][cols]cellKind // the reactor floor plan + stations [][2]int // station cells (row,col in grid space) + + crew map[string]*crewMember // by account id + names map[string]kit.Player // account id -> player (handle / character / persist) + order []string // join order of account ids (stable roster) + + faults []*fault + core float64 // shared integrity, 0..coreMax + + phase phase + startedAt time.Time // when the run began (for survival time) + survived time.Duration // final survival time once over + nextSpawn time.Time // when the next fault erupts + + now time.Time + lastNow time.Time + + // Leaderboard posting. Survival is a shared co-op metric (every boarded crew + // member shares the run length), so each tracked member is recorded with the + // same value. The keeper posts live on improvement (Record), on disconnect + // (FlushLeave, OnLeave), and periodically (FlushAll) so an abandoned-but- + // ticking reactor keeps recording. + sk *kit.ScoreKeeper + lastFlush time.Time // game-clock time of the last periodic flush + + hold *keyhold.Tracker // per-key held state, for FIRE hold-to-smother + frame *kit.Frame // long-lived render buffer, reused every frame +} + +// flushInterval is how much game time elapses between periodic FlushAll posts. +// Gated on r.Now() (no wall clock, no RNG) so a forgotten, still-ticking +// reactor keeps recording survival to the board. +const flushInterval = 10 * time.Second + +func newRoom(cfg kit.RoomConfig, svc kit.Services) *room { + rm := &room{ + cfg: cfg, + svc: svc, + crew: map[string]*crewMember{}, + names: map[string]kit.Player{}, + core: coreMax, + sk: kit.NewScoreKeeper(kit.OnImprove), + hold: keyhold.New(0), + frame: kit.NewFrame(), + } + rm.buildShip() + return rm +} + +// --- ship construction ------------------------------------------------------- + +// buildShip lays out the reactor: hull, interior bulkheads with doorways, a +// central core chamber, and the station cells. It is fully deterministic so the +// map is identical for everyone. +func (rm *room) buildShip() { + // Start every cell as floor, then wall the hull perimeter. + for r := 0; r < interiorRows; r++ { + for c := 0; c < cols; c++ { + if r == 0 || r == interiorRows-1 || c == 0 || c == cols-1 { + rm.grid[r][c] = cellWall + } else { + rm.grid[r][c] = cellFloor + } + } + } + + // Two vertical bulkheads split the deck into left / mid / right thirds, and + // two horizontal bulkheads split it into top / mid / bottom — nine bays. + vx := []int{cols / 3, 2 * cols / 3} + hy := []int{interiorRows / 3, 2 * interiorRows / 3} + for _, c := range vx { + for r := 1; r < interiorRows-1; r++ { + rm.grid[r][c] = cellWall + } + } + for _, r := range hy { + for c := 1; c < cols-1; c++ { + rm.grid[r][c] = cellWall + } + } + + // Cut doorways so every bay connects to its neighbours (gaps in the + // bulkheads). Doorways sit a few cells off each intersection. + door := func(r, c int) { + if r > 0 && r < interiorRows-1 && c > 0 && c < cols-1 { + rm.grid[r][c] = cellFloor + } + } + for _, c := range vx { + for _, r := range []int{hy[0] / 2, (hy[0] + hy[1]) / 2, (hy[1] + interiorRows) / 2} { + door(r, c) + door(r+1, c) + } + } + for _, r := range hy { + for _, c := range []int{vx[0] / 2, (vx[0] + vx[1]) / 2, (vx[1] + cols) / 2} { + door(r, c) + door(r, c+1) + } + } + + // The core chamber: a small box in the dead-center bay whose interior cells + // are the glowing core (impassable). Crew work the faults from the + // surrounding floor and corridor. + cr0, cr1 := hy[0]+2, hy[1]-2 + cc0, cc1 := vx[0]+5, vx[1]-5 + for r := cr0; r <= cr1; r++ { + for c := cc0; c <= cc1; c++ { + rm.grid[r][c] = cellCore + } + } + + // Stations: fixed, walkable cells scattered through the bays where faults + // erupt. Pick a tidy lattice and keep only the points that landed on open + // floor (so none collide with a wall or the core). + rm.stations = rm.stations[:0] + for r := 3; r < interiorRows-1; r += 4 { + for c := 4; c < cols-1; c += 9 { + if rm.grid[r][c] == cellFloor { + rm.grid[r][c] = cellStation + rm.stations = append(rm.stations, [2]int{r, c}) + } + } + } +} + +// walkable reports whether a crew body may occupy interior cell (r,c). Floor +// and station cells are walkable; walls and the core are not. +func (rm *room) walkable(r, c int) bool { + if r < 0 || r >= interiorRows || c < 0 || c >= cols { + return false + } + k := rm.grid[r][c] + return k == cellFloor || k == cellStation +} + +// --- lifecycle --------------------------------------------------------------- + +func (rm *room) OnStart(r kit.Room) { + r.SetInputContext(kit.CtxNav) + rm.now = r.Now() + rm.startedAt = rm.now + rm.lastFlush = rm.now + rm.scheduleNextSpawn() +} + +func (rm *room) OnJoin(r kit.Room, p kit.Player) { + rm.now = r.Now() + rm.names[p.AccountID] = p + m, existed := rm.crew[p.AccountID] + if !existed { + m = &crewMember{} + rm.crew[p.AccountID] = m + rm.order = append(rm.order, p.AccountID) + m.row, m.col = rm.spawnCell(r) + m.best = rm.loadBest(r, p) + } + m.joined = true + // The crew member's character IS their body: their glyph is the figure and + // their character's BACKGROUND colour becomes their crew colour everywhere + // it shows (body + roster). A zero Character (a host that doesn't declare + // the feature, or test doubles) reverts to '☺' and the join-order palette. + if c := p.Character; c.Glyph != "" { + for _, ru := range c.Glyph { + m.glyph = ru + break + } + m.color = kit.RGB(c.BgR, c.BgG, c.BgB) + } else { + m.glyph = '☺' + for i, id := range rm.order { + if id == p.AccountID { + m.color = palette[i%len(palette)] + break + } + } + } + rm.render(r) +} + +func (rm *room) OnLeave(r kit.Room, p kit.Player) { + rm.now = r.Now() + if m := rm.crew[p.AccountID]; m != nil { + m.joined = false + } + // Post the leaving crew member's current survival so a mid-run disconnect + // (or a host crash before OnClose) still reaches the board. + rm.sk.FlushLeave(r, p, kit.StatusDNF) + rm.render(r) +} + +func (rm *room) OnClose(r kit.Room) { + rm.now = r.Now() + rm.recordResults(r) +} + +// activeCrew counts crew currently in the room — the basis for spawn scaling +// and two-person fault eligibility (mirrors voidrunners keying off live ships). +func (rm *room) activeCrew() int { + n := 0 + for _, m := range rm.crew { + if m.joined { + n++ + } + } + if n < 1 { + n = 1 + } + return n +} + +// --- input ------------------------------------------------------------------- + +// OnInput moves a crew member or applies a fix interaction. A terminal has no +// key-up, so movement is one cell per press; FIRE/BREACH holds are derived from +// auto-repeat via the keyhold tracker and resolved each wake. +func (rm *room) OnInput(r kit.Room, p kit.Player, in kit.Input) { + rm.now = r.Now() + rm.hold.Observe(in, rm.now) + if rm.phase != phaseRunning { + rm.render(r) + return + } + m := rm.crew[p.AccountID] + if m == nil || !m.joined { + return + } + + // A jammed valve consumes raw printable keys: if the crew member stands on + // a valve fault, the next expected key in its sequence advances it (a wrong + // key resets). This is checked before navigation so the WASD-free sequence + // keys (which may also be hjkl) drive the valve, not the legs. + if in.Kind == kit.InputRune { + if f := rm.faultAt(m.row, m.col); f != nil && f.kind == faultValve { + rm.tapValve(r, f, in.Rune) + rm.render(r) + return + } + } + + switch kit.Resolve(in, kit.CtxNav) { + case kit.ActUp: + rm.move(m, -1, 0) + case kit.ActDown: + rm.move(m, 1, 0) + case kit.ActLeft: + rm.move(m, 0, -1) + case kit.ActRight: + rm.move(m, 0, 1) + case kit.ActConfirm: + // Space on a leak mashes it; on/adjacent a fire the hold tracker (set + // above via Observe) handles smothering each wake. + if f := rm.faultAt(m.row, m.col); f != nil && f.kind == faultLeak { + rm.mashLeak(r, f) + } + } + rm.render(r) +} + +func (rm *room) move(m *crewMember, dr, dc int) { + if rm.walkable(m.row+dr, m.col+dc) { + m.row += dr + m.col += dc + } +} + +// --- fix interactions -------------------------------------------------------- + +// faultAt returns the active fault on cell (r,c), or nil. +func (rm *room) faultAt(r, c int) *fault { + for _, f := range rm.faults { + if f.row == r && f.col == c { + return f + } + } + return nil +} + +func (rm *room) mashLeak(r kit.Room, f *fault) { + f.mashes++ + f.progress = float64(f.mashes) / float64(leakMashes) + if f.mashes >= leakMashes { + rm.completeFault(r, f) + } +} + +// tapValve advances the valve sequence if ru is the next expected key; any +// other key resets progress to the start. +func (rm *room) tapValve(r kit.Room, f *fault, ru rune) { + if f.seqAt < len(f.seq) && ru == f.seq[f.seqAt] { + f.seqAt++ + f.progress = float64(f.seqAt) / float64(len(f.seq)) + if f.seqAt >= len(f.seq) { + rm.completeFault(r, f) + } + return + } + // Wrong key: jam tightens again. + f.seqAt = 0 + f.progress = 0 +} + +// completeFault removes a fixed fault and credits whoever is standing on it. +func (rm *room) completeFault(r kit.Room, done *fault) { + for i, f := range rm.faults { + if f != done { + continue + } + rm.faults = append(rm.faults[:i], rm.faults[i+1:]...) + break + } + // Credit the fix to every crew member working the cell (both, for a breach). + for _, m := range rm.crew { + if !m.joined { + continue + } + if rm.adjacentToFault(m, done) { + m.fixes++ + } + } +} + +// adjacentToFault reports whether m is positioned to work fault f: on the cell +// for leak/valve/breach, on or orthogonally adjacent for a fire. +func (rm *room) adjacentToFault(m *crewMember, f *fault) bool { + dr := m.row - f.row + dc := m.col - f.col + if f.kind == faultFire { + return (dr == 0 && dc == 0) || + (dr == 0 && (dc == 1 || dc == -1)) || + (dc == 0 && (dr == 1 || dr == -1)) + } + return dr == 0 && dc == 0 +} + +// --- heartbeat --------------------------------------------------------------- + +// OnWake is the ~20Hz heartbeat: advance fixes that depend on continuous time +// (fire holds, breach holds, fire regrowth), drain the core by every active +// fault, erupt new faults on schedule, and end the run when the core dies. +func (rm *room) OnWake(r kit.Room) { + rm.now = r.Now() + dt := rm.step() + if rm.phase != phaseRunning { + rm.render(r) + return + } + + rm.resolveHolds(r, dt) + rm.drainCore(dt) + rm.maybeSpawn(r) + + if rm.core <= 0 { + rm.core = 0 + rm.phase = phaseOver + rm.survived = rm.now.Sub(rm.startedAt) + rm.postSurvival(r) + rm.recordResults(r) + rm.render(r) + return + } + + rm.postSurvival(r) + rm.maybeFlush(r) + rm.render(r) +} + +// postSurvival records the current shared survival time for every boarded crew +// member. OnImprove means a Post only reaches the board when the whole-second +// metric advances, so the steady state is one post per crew per survived +// second — the live feed for the leaderboard. +func (rm *room) postSurvival(r kit.Room) { + secs := int(rm.survivedSeconds()) + for _, id := range rm.order { + m := rm.crew[id] + if m == nil || !m.joined { + continue + } + if p, ok := rm.names[id]; ok { + rm.sk.Record(r, p, secs) + } + } +} + +// maybeFlush re-posts every tracked crew member's survival on a throttled +// game-clock interval, so a reactor left ticking with nobody improving the +// metric (or with no improvement since the last post) still keeps recording. +func (rm *room) maybeFlush(r kit.Room) { + if rm.now.Sub(rm.lastFlush) < flushInterval { + return + } + rm.lastFlush = rm.now + rm.sk.FlushAll(r, kit.StatusFinished) +} + +// step returns seconds since the last wake, clamped so a pause / hibernation +// can't drain the core in one giant tick. +func (rm *room) step() float64 { + dt := 0.05 + if !rm.lastNow.IsZero() { + if d := rm.now.Sub(rm.lastNow).Seconds(); d > 0 { + dt = math.Min(d, 0.2) + } + } + rm.lastNow = rm.now + return dt +} + +// resolveHolds advances the time-based fixes — FIRE (held space, on or +// adjacent) and BREACH (two crew on the cell) — and regrows fires nobody is +// holding. Mash/valve fixes happen in OnInput, not here. +func (rm *room) resolveHolds(r kit.Room, dt float64) { + // Tally how many qualifying crew are working each fire/breach this tick. + for _, f := range rm.faults { + f.holders = 0 + } + for _, m := range rm.crew { + if !m.joined { + continue + } + for _, f := range rm.faults { + if f.kind != faultFire && f.kind != faultBreach { + continue + } + if !rm.adjacentToFault(m, f) { + continue + } + if f.kind == faultFire && !rm.hold.HeldRune(' ', rm.now) { + continue + } + f.holders++ + } + } + + done := rm.faults[:0:0] + for _, f := range rm.faults { + switch f.kind { + case faultFire: + if f.holders > 0 { + f.progress += dt / fireHoldSecs + } else { + f.progress -= dt * fireRegrow / fireHoldSecs + } + f.progress = clampf(f.progress, 0, 1) + case faultBreach: + if f.holders >= 2 { + f.progress += dt / breachHoldSecs + } else { + // Breach needs both bodies; it bleeds back without the pair. + f.progress -= dt * fireRegrow / breachHoldSecs + } + f.progress = clampf(f.progress, 0, 1) + } + if f.progress >= 1 { + done = append(done, f) + } + } + for _, f := range done { + rm.completeFault(r, f) + } +} + +// drainCore subtracts the integrity eaten by every active fault this tick. +func (rm *room) drainCore(dt float64) { + var drain float64 + for _, f := range rm.faults { + base := 0.0 + switch f.kind { + case faultLeak: + base = leakDrain + case faultFire: + base = fireDrain + case faultValve: + base = valveDrain + case faultBreach: + base = breachDrain + } + age := rm.now.Sub(f.born).Seconds() + drain += base + age*ageDrainMul + } + rm.core = clampf(rm.core-drain*dt, 0, coreMax) +} + +// --- spawning ---------------------------------------------------------------- + +// maybeSpawn erupts a new fault when the schedule says so (and there is room), +// then reschedules the next eruption. +func (rm *room) maybeSpawn(r kit.Room) { + if !rm.now.After(rm.nextSpawn) { + return + } + if len(rm.faults) < maxFaults { + rm.spawnFault(r) + } + rm.scheduleNextSpawn() +} + +// spawnInterval is the gap before the next fault: it shrinks as the run goes on +// (panic ramps), and is stretched sub-linearly by crew size so a bigger crew +// faces fewer faults per person. Mirrors voidrunners' craterTarget() solo-vs- +// multi adaptation: solo gets the gentlest cadence; crew scales the load up, +// but only by sqrt(crew), so recruiting friends genuinely eases the shift. +func (rm *room) spawnInterval() time.Duration { + elapsed := rm.now.Sub(rm.startedAt).Seconds() + // Ramp: the base interval decays toward the floor with elapsed time. + ramp := spawnBase * math.Exp(-elapsed/(spawnRampSecs*6)) + if ramp < spawnFloor { + ramp = spawnFloor + } + // Sub-linear crew scaling: divide the per-spawn gap by sqrt(crew) so total + // load grows slower than crew size (load/crew falls as crew rises). + crew := float64(rm.activeCrew()) + gap := ramp / math.Sqrt(crew) + if gap < spawnFloor/2 { + gap = spawnFloor / 2 + } + return time.Duration(gap * float64(time.Second)) +} + +func (rm *room) scheduleNextSpawn() { + rm.nextSpawn = rm.now.Add(rm.spawnInterval()) +} + +// spawnFault erupts one fault at a random free station. With 2+ crew it may be +// a two-person BREACH; with a lone engineer it never is. +func (rm *room) spawnFault(r kit.Room) { + rng := r.Rand() + cell, ok := rm.freeStation(rng) + if !ok { + return + } + kind := rm.pickFaultKind(rng) + f := &fault{kind: kind, row: cell[0], col: cell[1], born: rm.now} + if kind == faultValve { + f.seq = rm.makeValveSeq(rng) + } + rm.faults = append(rm.faults, f) +} + +// pickFaultKind chooses a fault type. BREACH is only ever offered with 2+ +// active crew (the hard rule: never spawn a two-person fault solo), and only +// occasionally even then. +func (rm *room) pickFaultKind(rng interface { + Intn(int) int + Float64() float64 +}) faultKind { + if rm.activeCrew() >= 2 && rng.Float64() < 0.2 { + return faultBreach + } + switch rng.Intn(3) { + case 0: + return faultLeak + case 1: + return faultFire + default: + return faultValve + } +} + +// valveKeys are the printable keys a jammed valve may ask for (lowercase +// letters that are NOT movement aliases h/j/k/l, and not q which leaves). +var valveKeys = []rune("abcdefginoprstuvwxyz") + +func (rm *room) makeValveSeq(rng interface{ Intn(int) int }) []rune { + n := valveMinKeys + rng.Intn(valveMaxKeys-valveMinKeys+1) + seq := make([]rune, n) + for i := range seq { + seq[i] = valveKeys[rng.Intn(len(valveKeys))] + } + return seq +} + +// freeStation returns a random station cell that currently has no fault on it. +func (rm *room) freeStation(rng interface{ Intn(int) int }) ([2]int, bool) { + if len(rm.stations) == 0 { + return [2]int{}, false + } + start := rng.Intn(len(rm.stations)) + for i := 0; i < len(rm.stations); i++ { + st := rm.stations[(start+i)%len(rm.stations)] + if rm.faultAt(st[0], st[1]) == nil { + return st, true + } + } + return [2]int{}, false +} + +// spawnCell picks a walkable starting cell for a fresh crew member, away from +// the core, deterministically chosen from the open floor. +func (rm *room) spawnCell(r kit.Room) (int, int) { + rng := r.Rand() + for try := 0; try < 40; try++ { + row := 1 + rng.Intn(interiorRows-2) + col := 1 + rng.Intn(cols-2) + if rm.grid[row][col] == cellFloor { + return row, col + } + } + return 1, 1 +} + +// --- durable best survival --------------------------------------------------- + +// recordResults persists each crew member's best survival time (the shared run +// length) to durable KV so the arcade leaderboard can read it. +func (rm *room) recordResults(r kit.Room) { + secs := int(rm.survivedSeconds()) + for id := range rm.crew { + p, ok := rm.names[id] + if !ok { + continue + } + acct := r.Services().Accounts.For(p) + if acct == nil { + continue + } + _ = acct.Store().Set(context.Background(), "best", + []byte(strconv.Itoa(secs)), kit.MergeMax) + } +} + +func (rm *room) loadBest(r kit.Room, p kit.Player) int { + acct := r.Services().Accounts.For(p) + if acct == nil { + return 0 + } + v, ok, err := acct.Store().Get(context.Background(), "best") + if err != nil || !ok { + return 0 + } + n, err := strconv.Atoi(strings.TrimSpace(string(v))) + if err != nil { + return 0 + } + return n +} + +// survivedSeconds is the run length: live elapsed while running, frozen once +// the core dies. +func (rm *room) survivedSeconds() float64 { + if rm.phase == phaseOver { + return rm.survived.Seconds() + } + return rm.now.Sub(rm.startedAt).Seconds() +} diff --git a/games/bcook/meltdown/room_test.go b/games/bcook/meltdown/room_test.go new file mode 100644 index 0000000..bf562b3 --- /dev/null +++ b/games/bcook/meltdown/room_test.go @@ -0,0 +1,523 @@ +package main + +import ( + "testing" + "time" + + kit "github.com/shellcade/kit/v2" + "github.com/shellcade/kit/v2/kittest" +) + +// newTestRoom builds a started room driven by a kittest.Room (deterministic +// clock + rng), with no crew joined yet. +func newTestRoom(t *testing.T, handles ...string) (*room, *kittest.Room) { + t.Helper() + players := make([]kit.Player, len(handles)) + for i, h := range handles { + players[i] = kittest.Player(h) + } + tr := kittest.NewRoom(players...) + rm := newRoom(tr.Cfg, tr.Services()) + rm.OnStart(tr) + return rm, tr +} + +func keyRune(ru rune) kit.Input { return kit.Input{Kind: kit.InputRune, Rune: ru} } +func keyNamed(k kit.Key) kit.Input { + return kit.Input{Kind: kit.InputKey, Key: k} +} + +// standOn moves a crew member directly onto a cell (test helper that bypasses +// the walls — we are positioning, not pathfinding). +func standOn(m *crewMember, row, col int) { m.row, m.col = row, col } + +// --- map invariants ---------------------------------------------------------- + +func TestShipHasStationsAndCore(t *testing.T) { + rm, _ := newTestRoom(t, "alice") + if len(rm.stations) < 8 { + t.Fatalf("want a decent spread of stations, got %d", len(rm.stations)) + } + cores := 0 + for r := 0; r < interiorRows; r++ { + for c := 0; c < cols; c++ { + if rm.grid[r][c] == cellCore { + cores++ + } + } + } + if cores == 0 { + t.Fatal("no core cells in the reactor") + } + // Every station must be walkable (so a crew member can reach it to work it). + for _, st := range rm.stations { + if !rm.walkable(st[0], st[1]) { + t.Fatalf("station %v is not walkable", st) + } + } +} + +func TestWallsBlockMovement(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + m := rm.crew[a.AccountID] + // Park against the top hull and try to walk into it. + standOn(m, 1, 5) + rm.OnInput(tr, a, keyNamed(kit.KeyUp)) // row 0 is the hull wall + if m.row != 1 { + t.Fatalf("crew walked into a wall: row %d", m.row) + } +} + +// --- LEAK: mash space -------------------------------------------------------- + +func TestLeakPatchedByMashing(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + m := rm.crew[a.AccountID] + + st := rm.stations[0] + standOn(m, st[0], st[1]) + rm.faults = []*fault{{kind: faultLeak, row: st[0], col: st[1], born: tr.Clock}} + + for i := 0; i < leakMashes-1; i++ { + rm.OnInput(tr, a, keyRune(' ')) + } + if len(rm.faults) != 1 { + t.Fatalf("leak should still be open after %d mashes", leakMashes-1) + } + rm.OnInput(tr, a, keyRune(' ')) // the final mash + if len(rm.faults) != 0 { + t.Fatalf("leak should be patched after %d mashes, %d remain", leakMashes, len(rm.faults)) + } + if m.fixes != 1 { + t.Fatalf("patching a leak should credit a fix, got %d", m.fixes) + } +} + +func TestLeakIgnoresMashWhenNotStandingOnIt(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + m := rm.crew[a.AccountID] + + st := rm.stations[0] + standOn(m, st[0]+0, st[1]) // ensure on a known floor cell, not the leak + // Put the crew member somewhere that is NOT the leak cell. + other := rm.stations[1] + standOn(m, other[0], other[1]) + rm.faults = []*fault{{kind: faultLeak, row: st[0], col: st[1], born: tr.Clock}} + + for i := 0; i < leakMashes*2; i++ { + rm.OnInput(tr, a, keyRune(' ')) + } + if len(rm.faults) != 1 { + t.Fatal("a leak should not be patched from a distance") + } +} + +// --- FIRE: hold space, regrows on release ------------------------------------ + +func TestFireExtinguishedByHolding(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + m := rm.crew[a.AccountID] + + st := rm.stations[0] + standOn(m, st[0], st[1]) // stand on it (also counts as adjacent) + rm.faults = []*fault{{kind: faultFire, row: st[0], col: st[1], born: tr.Clock}} + + // Hold space across several wakes: each wake observes a fresh space press + // (auto-repeat) and integrates hold time. + for i := 0; i < 60 && len(rm.faults) > 0; i++ { + rm.OnInput(tr, a, keyRune(' ')) // repeat keeps the hold alive + tr.Advance(50 * time.Millisecond) + rm.OnWake(tr) + } + if len(rm.faults) != 0 { + t.Fatalf("fire should be out after a sustained hold, %d remain", len(rm.faults)) + } + if m.fixes != 1 { + t.Fatalf("extinguishing a fire should credit a fix, got %d", m.fixes) + } +} + +func TestFireRegrowsWhenReleased(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + m := rm.crew[a.AccountID] + + st := rm.stations[0] + standOn(m, st[0], st[1]) + f := &fault{kind: faultFire, row: st[0], col: st[1], born: tr.Clock} + rm.faults = []*fault{f} + + // Hold briefly to build some progress. + rm.OnInput(tr, a, keyRune(' ')) + tr.Advance(50 * time.Millisecond) + rm.OnWake(tr) + got := f.progress + if got <= 0 { + t.Fatal("holding should build fire progress") + } + + // Now stop pressing space. The hold tracker lingers, so advance well past + // the linger window before checking that progress decays. + for i := 0; i < 30; i++ { + tr.Advance(50 * time.Millisecond) + rm.OnWake(tr) + } + if len(rm.faults) == 0 { + t.Fatal("an unattended fire should not extinguish itself") + } + if f.progress >= got { + t.Fatalf("released fire should regrow (progress fell from %.3f to %.3f)", got, f.progress) + } +} + +// --- VALVE: key sequence ----------------------------------------------------- + +func TestValveOpenedByCorrectSequence(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + m := rm.crew[a.AccountID] + + st := rm.stations[0] + standOn(m, st[0], st[1]) + seq := []rune{'a', 'b', 'c'} + rm.faults = []*fault{{kind: faultValve, row: st[0], col: st[1], born: tr.Clock, seq: seq}} + + for _, ru := range seq { + rm.OnInput(tr, a, keyRune(ru)) + } + if len(rm.faults) != 0 { + t.Fatalf("valve should open after the full sequence, %d remain", len(rm.faults)) + } + if m.fixes != 1 { + t.Fatalf("opening a valve should credit a fix, got %d", m.fixes) + } +} + +func TestValveWrongKeyResetsProgress(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + m := rm.crew[a.AccountID] + + st := rm.stations[0] + standOn(m, st[0], st[1]) + f := &fault{kind: faultValve, row: st[0], col: st[1], born: tr.Clock, seq: []rune{'a', 'b', 'c'}} + rm.faults = []*fault{f} + + rm.OnInput(tr, a, keyRune('a')) + rm.OnInput(tr, a, keyRune('b')) + if f.seqAt != 2 { + t.Fatalf("two correct keys should advance to 2, got %d", f.seqAt) + } + rm.OnInput(tr, a, keyRune('z')) // wrong + if f.seqAt != 0 || f.progress != 0 { + t.Fatalf("a wrong key must reset the valve, got seqAt=%d progress=%.2f", f.seqAt, f.progress) + } + if len(rm.faults) != 1 { + t.Fatal("valve should remain after a wrong key") + } +} + +// --- BREACH: two crew, never solo -------------------------------------------- + +func TestBreachNeedsTwoCrew(t *testing.T) { + rm, tr := newTestRoom(t, "alice", "bob") + a, b := tr.Players[0], tr.Players[1] + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + ma, mb := rm.crew[a.AccountID], rm.crew[b.AccountID] + + st := rm.stations[0] + standOn(ma, st[0], st[1]) + // bob stands elsewhere first. + other := rm.stations[1] + standOn(mb, other[0], other[1]) + f := &fault{kind: faultBreach, row: st[0], col: st[1], born: tr.Clock} + rm.faults = []*fault{f} + + // Only alice on it: progress must not build. + for i := 0; i < 40; i++ { + tr.Advance(50 * time.Millisecond) + rm.OnWake(tr) + } + if len(rm.faults) == 0 { + t.Fatal("a breach with one crew should not seal") + } + + // Both on it now: it seals. + standOn(mb, st[0], st[1]) + for i := 0; i < 60 && len(rm.faults) > 0; i++ { + tr.Advance(50 * time.Millisecond) + rm.OnWake(tr) + } + if len(rm.faults) != 0 { + t.Fatalf("a breach with two crew should seal, %d remain", len(rm.faults)) + } + if ma.fixes != 1 || mb.fixes != 1 { + t.Fatalf("a sealed breach should credit both crew, got %d / %d", ma.fixes, mb.fixes) + } +} + +func TestBreachNeverSpawnsSolo(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + rng := tr.Rand() + // pickFaultKind must never return a breach with a lone engineer, however + // the rng falls. + for i := 0; i < 5000; i++ { + if rm.pickFaultKind(rng) == faultBreach { + t.Fatal("a two-person breach was offered to a solo crew") + } + } +} + +func TestBreachCanSpawnWithCrew(t *testing.T) { + rm, tr := newTestRoom(t, "alice", "bob") + rm.OnJoin(tr, tr.Players[0]) + rm.OnJoin(tr, tr.Players[1]) + rng := tr.Rand() + sawBreach := false + for i := 0; i < 5000 && !sawBreach; i++ { + if rm.pickFaultKind(rng) == faultBreach { + sawBreach = true + } + } + if !sawBreach { + t.Fatal("a 2-crew shift should occasionally offer a breach") + } +} + +// --- core damage from neglect ------------------------------------------------ + +func TestNeglectedFaultsDrainCore(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + rm.OnJoin(tr, tr.Players[0]) + rm.faults = []*fault{ + {kind: faultLeak, row: rm.stations[0][0], col: rm.stations[0][1], born: tr.Clock}, + {kind: faultFire, row: rm.stations[1][0], col: rm.stations[1][1], born: tr.Clock}, + } + before := rm.core + for i := 0; i < 20; i++ { + tr.Advance(50 * time.Millisecond) + rm.OnWake(tr) + } + if rm.core >= before { + t.Fatalf("neglected faults should drain the core (was %.1f, now %.1f)", before, rm.core) + } +} + +func TestCoreDeathEndsRunAndFreezesTime(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + rm.OnJoin(tr, tr.Players[0]) + rm.core = 1.0 + rm.faults = []*fault{{kind: faultBreach, row: rm.stations[0][0], col: rm.stations[0][1], born: tr.Clock}} + + for i := 0; i < 200 && rm.phase == phaseRunning; i++ { + tr.Advance(50 * time.Millisecond) + rm.OnWake(tr) + } + if rm.phase != phaseOver { + t.Fatal("the run should end when the core hits zero") + } + frozen := rm.survivedSeconds() + // Survival time must not keep climbing after the meltdown. + tr.Advance(5 * time.Second) + rm.OnWake(tr) + if rm.survivedSeconds() != frozen { + t.Fatalf("survival time kept ticking after meltdown: %.2f -> %.2f", frozen, rm.survivedSeconds()) + } +} + +// --- spawn-rate scaling by crew size ----------------------------------------- + +func TestSpawnIntervalShrinksWithCrew(t *testing.T) { + rm, tr := newTestRoom(t, "alice", "bob", "cleo") + rm.now = tr.Clock // pin elapsed = 0 for an apples-to-apples comparison + + rm.OnJoin(tr, tr.Players[0]) + solo := rm.spawnInterval() + + rm.OnJoin(tr, tr.Players[1]) + rm.OnJoin(tr, tr.Players[2]) + rm.now = tr.Clock + trio := rm.spawnInterval() + + if !(trio < solo) { + t.Fatalf("more crew should shorten the spawn gap (solo %v, trio %v)", solo, trio) + } + // Sub-linear: a 3x crew must NOT triple the spawn rate. The gap should fall + // by less than 3x, i.e. trio > solo/3. + if trio <= solo/3 { + t.Fatalf("crew scaling should be sub-linear: trio gap %v should exceed solo/3 %v", trio, solo/3) + } +} + +func TestSpawnIntervalShrinksOverTime(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + rm.OnJoin(tr, tr.Players[0]) + rm.now = rm.startedAt + early := rm.spawnInterval() + rm.now = rm.startedAt.Add(3 * time.Minute) + late := rm.spawnInterval() + if !(late < early) { + t.Fatalf("the panic should ramp: late gap %v should be shorter than early %v", late, early) + } + floor := time.Duration(spawnFloor / 2 * float64(time.Second)) + if late < floor { + t.Fatalf("spawn gap should respect the floor %v, got %v", floor, late) + } +} + +// --- survival scoring + persistence ------------------------------------------ + +func TestSurvivalScorePersisted(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + + // Run a few seconds then end it. + tr.Advance(7 * time.Second) + rm.now = tr.Clock + rm.core = 0 + rm.OnWake(tr) + if rm.phase != phaseOver { + t.Fatal("run should be over") + } + + best := rm.loadBest(tr, a) + if best < 7 { + t.Fatalf("best survival should persist at least the run length, got %d", best) + } +} + +// --- general no-panic soak --------------------------------------------------- + +func TestSoakNoPanic(t *testing.T) { + rm, tr := newTestRoom(t, "alice", "bob") + a, b := tr.Players[0], tr.Players[1] + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + + inputs := []kit.Input{ + keyNamed(kit.KeyLeft), keyNamed(kit.KeyRight), keyNamed(kit.KeyUp), + keyNamed(kit.KeyDown), keyRune(' '), keyRune('a'), keyRune('x'), + } + players := []kit.Player{a, b} + for i := 0; i < 1200; i++ { + p := players[i%2] + rm.OnInput(tr, p, inputs[i%len(inputs)]) + tr.Advance(50 * time.Millisecond) + rm.OnWake(tr) + // Faults must never exceed the cap, and crew must stay walkable. + if len(rm.faults) > maxFaults { + t.Fatalf("fault cap exceeded: %d", len(rm.faults)) + } + for id, m := range rm.crew { + if !rm.walkable(m.row, m.col) { + t.Fatalf("crew %s stands on a non-walkable cell (%d,%d)", id, m.row, m.col) + } + } + } +} + +func TestComposeRendersFrame(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] + rm.OnJoin(tr, a) + f := kit.NewFrame() + rm.composeFor(f, a) + if len(f.Cells) != kit.Rows || len(f.Cells[0]) != kit.Cols { + t.Fatal("frame is not 24x80") + } +} + +// TestRenderReusesFrame asserts render keeps using the one long-lived buffer. +func TestRenderReusesFrame(t *testing.T) { + rm, tr := newTestRoom(t, "alice", "bob") + for _, p := range tr.Players { + rm.OnJoin(tr, p) + } + before := rm.frame + rm.render(tr) + rm.render(tr) + if rm.frame != before { + t.Fatal("render replaced rm.frame — it must reuse the single long-lived buffer") + } +} + +// TestRosterCharacterRendersBesideName asserts each crew member's character +// tile sits before their name on the roster, in join order. +func TestRosterCharacterRendersBesideName(t *testing.T) { + rm, tr := newTestRoom(t, "alice", "bob") + a, b := tr.Players[0], tr.Players[1] + a.Character = kit.Character{Glyph: "λ", InkR: 0x39, InkG: 0xFF, InkB: 0x14, BgR: 0x2D, BgG: 0x1B, BgB: 0x4E, Fallback: 'L'} + b.Character = kit.Character{Glyph: "@", InkR: 1, InkG: 2, InkB: 3, BgR: 4, BgG: 5, BgB: 6, Fallback: '@'} + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + + f := kit.NewFrame() + rm.composeFor(f, a) + + // The first roster tile is alice's character at col 1, name 'a' two cols on. + if got := f.Cells[0][1]; got != kit.CharacterCell(a.Character) { + t.Errorf("first roster cell = %+v, want alice's character tile", got) + } + if f.Cells[0][3].Rune != 'a' { + t.Errorf("name should follow the character tile, got %q", f.Cells[0][3].Rune) + } +} + +// TestCrewBodyUsesCharacterColour asserts a crew member's body glyph is their +// character glyph in the character's bg colour (composed for another viewer so +// the cell is free of reverse-video). +func TestCrewBodyUsesCharacterColour(t *testing.T) { + rm, tr := newTestRoom(t, "alice", "bob") + a, b := tr.Players[0], tr.Players[1] + a.Character = kit.Character{Glyph: "λ", InkR: 0x39, InkG: 0xFF, InkB: 0x14, BgR: 0x2D, BgG: 0x1B, BgB: 0x4E, Fallback: 'L'} + rm.OnJoin(tr, a) + rm.OnJoin(tr, b) + + ma, mb := rm.crew[a.AccountID], rm.crew[b.AccountID] + want := kit.RGB(a.Character.BgR, a.Character.BgG, a.Character.BgB) + if ma.color != want { + t.Fatalf("crew colour = %v, want character bg %v", ma.color, want) + } + standOn(ma, 2, 2) + standOn(mb, 20, 70) // park the rival clear + + f := kit.NewFrame() + rm.composeFor(f, b) + cell := f.Cells[top+2][2] + if cell.Rune != 'λ' { + t.Fatalf("body rune = %q, want the character glyph 'λ'", cell.Rune) + } + if cell.FG != want { + t.Fatalf("body FG = %v, want character bg colour %v", cell.FG, want) + } +} + +// TestZeroCharacterCrewUsesPalette is the regression guard: a member with no +// character keeps the '☺' body and a join-order palette colour. +func TestZeroCharacterCrewUsesPalette(t *testing.T) { + rm, tr := newTestRoom(t, "alice") + a := tr.Players[0] // kittest player: zero Character + rm.OnJoin(tr, a) + m := rm.crew[a.AccountID] + if m.glyph != '☺' { + t.Fatalf("body glyph = %q, want '☺' for a zero character", m.glyph) + } + if m.color != palette[0] { + t.Fatalf("crew colour = %v, want palette[0] %v", m.color, palette[0]) + } +} diff --git a/games/bcook/meltdown/smoke.yaml b/games/bcook/meltdown/smoke.yaml new file mode 100644 index 0000000..348ad23 --- /dev/null +++ b/games/bcook/meltdown/smoke.yaml @@ -0,0 +1,28 @@ +seed: 7 +seats: 2 +heartbeat: 50ms +steps: + # (a) The reactor at start: the hull and bulkheads drawn around the glowing + # core at full integrity, stations dotted through the bays. One heartbeat + # settles the opening frame. + - advance: 50ms + - shot: reactor + + # The first faults erupt at the stations and begin gnawing the core. + - advance: 8s + - shot: first-faults + + # The faults pile up and the core slides toward critical — several hazards + # glow at once and the integrity readout falls. + - advance: 8s + - shot: many-faults + + # Core critical: the klaxon header flashes !! ALARM !! over the readout. + - advance: 6s + - shot: alarm + + # The reactor finishes dying: the meltdown end screen with the crew's + # survival time. + - advance: 6s + - advance: 6s + - shot: meltdown diff --git a/games/bcook/meltdown/types.go b/games/bcook/meltdown/types.go new file mode 100644 index 0000000..eac1336 --- /dev/null +++ b/games/bcook/meltdown/types.go @@ -0,0 +1,168 @@ +package main + +import ( + "time" + + kit "github.com/shellcade/kit/v2" +) + +// Canvas geometry. The canvas is 80x24; row 0 is the crew roster / alarm +// header and row 23 is the controls/status bar, leaving rows 1..22 for the +// reactor ship interior. +const ( + cols = kit.Cols // 80 + top = 1 // first interior row + bottom = 22 // last interior row (inclusive) +) + +// The reactor floor plan is built at runtime by buildShip (see room.go) into a +// fixed interiorRows x cols grid of cellKind: an outer hull, interior bulkheads +// carving four corner rooms around a central glowing core chamber, doorways cut +// so every room connects, and stations sprinkled on the floor where faults +// erupt. Building it in code keeps the geometry provably rectangular (no +// ragged hand-drawn strings) and the connectivity easy to reason about. + +// rows in the interior. +const interiorRows = bottom - top + 1 // 22 + +// Cell kinds derived from the blueprint. +type cellKind uint8 + +const ( + cellFloor cellKind = iota + cellWall + cellCore + cellStation +) + +// palette assigns each crew member a distinct bright color by join order. +var palette = []kit.Color{ + kit.RGB(0x4f, 0xd6, 0xff), // cyan + kit.RGB(0xff, 0x8a, 0x4f), // orange + kit.RGB(0x7d, 0xff, 0x6b), // green + kit.RGB(0xff, 0x6b, 0xc7), // pink + kit.RGB(0xb9, 0x8a, 0xff), // purple + kit.RGB(0xff, 0xe1, 0x55), // yellow +} + +var ( + wallColor = kit.RGB(0x44, 0x55, 0x66) + coreHot = kit.RGB(0xff, 0xe1, 0x55) + coreWarm = kit.RGB(0xff, 0x8a, 0x4f) + coreCold = kit.RGB(0x88, 0x44, 0x44) +) + +// faultKind enumerates the four hazards. +type faultKind uint8 + +const ( + faultLeak faultKind = iota // ≈ stand on it, mash space + faultFire // ▲ stand adjacent, HOLD space + faultValve // Φ stand on it, type the shown key sequence + faultBreach // ◊ two crew stand on it together (2+ crew only) +) + +// faultGlyph is the icon drawn for an active fault. +var faultGlyph = map[faultKind]rune{ + faultLeak: '≈', + faultFire: '▲', + faultValve: 'Φ', + faultBreach: '◊', +} + +// faultColor tints each fault type. +var faultColor = map[faultKind]kit.Color{ + faultLeak: kit.RGB(0x55, 0xbb, 0xff), + faultFire: kit.RGB(0xff, 0x66, 0x33), + faultValve: kit.RGB(0xff, 0xcc, 0x44), + faultBreach: kit.RGB(0xff, 0x44, 0x88), +} + +// faultName is the human label shown on a fix progress bar. +var faultName = map[faultKind]string{ + faultLeak: "LEAK", + faultFire: "FIRE", + faultValve: "VALVE", + faultBreach: "BREACH", +} + +// --- tuning ------------------------------------------------------------------ + +const ( + coreMax = 100.0 // full core integrity + + // Each active fault drains the core every second. Worse faults bite harder, + // and a fault that has festered (age) bites a little harder still. + leakDrain = 1.4 + fireDrain = 2.2 + valveDrain = 1.8 + breachDrain = 3.0 + ageDrainMul = 0.06 // extra drain per second a fault has been alive + + // Leak: how many space mashes patch it. + leakMashes = 6 + // Fire: seconds of continuous HOLD to extinguish; it regrows toward full + // at regrowRate when nobody is holding on it. + fireHoldSecs = 1.6 + fireRegrow = 0.7 // progress lost per second when released + // Valve: 3-4 keys in the shown sequence; a wrong key resets your progress. + valveMinKeys = 3 + valveMaxKeys = 4 + // Breach: seconds two crew must stand on it together. + breachHoldSecs = 1.2 + + // Spawn cadence. The base interval shrinks as the run goes on (panic ramps + // up), and is divided down toward a floor. Crew scaling is sub-linear so a + // bigger crew faces proportionally fewer faults each. + spawnBase = 5.0 // seconds between spawns at the very start (solo) + spawnFloor = 1.1 // never spawn faster than this + spawnRampSecs = 14.0 + maxFaults = 10 // hard cap on simultaneous faults +) + +// crewMember is one player's engineer. State is keyed by Player.AccountID so +// it survives room hibernation (connections change across a freeze; accounts +// don't). +type crewMember struct { + row, col int // cell position in the interior + fixes int // faults this member has completed this run + best int // all-time best survival seconds (seeded from durable KV) + glyph rune // body glyph: the player's character, or '☺' when none + color kit.Color // body color: the character's BG colour, or a palette pick + joined bool // currently in the room +} + +// fault is one active hazard at a station. +type fault struct { + kind faultKind + row, col int + born time.Time // when it erupted (for age-based extra drain) + // progress is fix completion in [0,1]; the meaning depends on kind: + // leak — mashes/leakMashes + // fire — held seconds / fireHoldSecs (decays when nobody holds) + // valve — keys matched / len(seq) + // breach — held seconds (two crew) / breachHoldSecs + progress float64 + mashes int // leak only: space mashes landed so far + seq []rune // valve only: the key sequence to type + seqAt int // valve only: how many keys matched so far + holders int // crew currently working it this tick (fire/breach) +} + +// phase is the room's lifecycle state. +type phase uint8 + +const ( + phaseRunning phase = iota + phaseOver +) + +func clampf(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} diff --git a/games/bcook/meltdown/zz_snapshot_test.go b/games/bcook/meltdown/zz_snapshot_test.go new file mode 100644 index 0000000..f975156 --- /dev/null +++ b/games/bcook/meltdown/zz_snapshot_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "fmt" + "strings" + "testing" + + kit "github.com/shellcade/kit/v2" +) + +// TestSnapshot prints a readable rune grid of one composed frame. Run with: +// +// go test -run TestSnapshot -v +func TestSnapshot(t *testing.T) { + rm, tr := newTestRoom(t, "alice", "bob", "cleo") + for _, p := range tr.Players { + rm.OnJoin(tr, p) + } + a, b, c := tr.Players[0], tr.Players[1], tr.Players[2] + // Pose the crew and some faults so the frame is lively. + st := rm.stations + standOn(rm.crew[a.AccountID], st[0][0], st[0][1]) + standOn(rm.crew[b.AccountID], st[3][0], st[3][1]) + standOn(rm.crew[c.AccountID], st[6][0], st[6][1]) + rm.crew[a.AccountID].fixes = 4 + rm.crew[b.AccountID].fixes = 2 + + rm.faults = []*fault{ + {kind: faultLeak, row: st[0][0], col: st[0][1], born: tr.Clock, progress: 0.5}, + {kind: faultFire, row: st[2][0], col: st[2][1], born: tr.Clock}, + {kind: faultValve, row: st[4][0], col: st[4][1], born: tr.Clock, seq: []rune{'r', 'e', 'd'}, seqAt: 1, progress: 0.33}, + {kind: faultBreach, row: st[6][0], col: st[6][1], born: tr.Clock}, + } + rm.core = 38 + + f := kit.NewFrame() + rm.composeFor(f, a) + var sb strings.Builder + sb.WriteString("+" + strings.Repeat("-", kit.Cols) + "+\n") + for row := 0; row < kit.Rows; row++ { + sb.WriteByte('|') + for col := 0; col < kit.Cols; col++ { + ru := f.Cells[row][col].Rune + if ru == 0 { + ru = ' ' + } + sb.WriteRune(ru) + } + sb.WriteString("|\n") + } + sb.WriteString("+" + strings.Repeat("-", kit.Cols) + "+\n") + fmt.Print(sb.String()) +}