M0: skelett, byggkedja och dokumentation för JP Subway Pussel

- Go 1.27 + Ebitengine v2.10, intern upplösning 320x180 med heltalsskalning
- internal/res (resursuppslag), internal/rng (seedad slump), internal/level
  (banformat, validering, BFS-lösbarhet) med tester; internal/archtest
  kontrollerar att bara presentationslagret beror på Ebitengine
- tools/build: debug -> build/debug/, release -> build/release/<os>-<arch>/
  med resources/ bredvid binären; Windows korsbyggs utan C-verktyg
- VS Code tasks (build/test/release) och launch (dlv)
- Första banan 001-flygplatsen, sv/en-texter, skyltordbok, placeholder-sprite
- doc/: spelbeskrivning (renskriven), mål och tester, roadmap med teknikval
  (M0–M12), plan.md; README med bygg- och beroendeinformation

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 18:52:10 +02:00
commit c1f9546af6
32 changed files with 2165 additions and 0 deletions

48
internal/rng/rng.go Normal file
View File

@@ -0,0 +1,48 @@
// Package rng ger seedade slumptalsgeneratorer.
//
// Allt som ska vara reproducerbart (minispel, oändligt läge, event-timing)
// måste ta sin slump härifrån med en känd seed, aldrig från den globala
// generatorn. Seeden sparas i sparfilen så att en laddad omgång ger samma
// utfall som den avbrutna.
package rng
import (
crand "crypto/rand"
"encoding/binary"
"math/rand/v2"
)
// Seed är en 64-bitars seed. Två värden för att kunna avleda del-seeds
// (t.ex. per bana eller per minispel) utan att de påverkar varandra.
type Seed struct {
A, B uint64
}
// NewSeed drar en färsk slumpmässig seed från operativsystemet.
func NewSeed() Seed {
var b [16]byte
if _, err := crand.Read(b[:]); err != nil {
panic("rng: kunde inte läsa slump från OS: " + err.Error())
}
return Seed{binary.LittleEndian.Uint64(b[:8]), binary.LittleEndian.Uint64(b[8:])}
}
// FromInt skapar en seed från ett enkelt heltal (praktiskt i tester och för
// seeds som spelaren skriver in manuellt).
func FromInt(n uint64) Seed { return Seed{A: n, B: n ^ 0x9E3779B97F4A7C15} }
// Derive avleder en deterministisk del-seed för en namngiven kontext, så att
// t.ex. biljettautomatens minispel på bana 3 alltid får samma slump oavsett
// vad spelaren gjort innan.
func (s Seed) Derive(context string) Seed {
h := rand.New(rand.NewPCG(s.A, s.B))
a, b := h.Uint64(), h.Uint64()
for _, c := range context {
a ^= uint64(c) * 0x100000001B3
b = b*31 + uint64(c)
}
return Seed{A: a, B: b}
}
// New returnerar en generator för seeden. Samma seed ger alltid samma sekvens.
func (s Seed) New() *rand.Rand { return rand.New(rand.NewPCG(s.A, s.B)) }

38
internal/rng/rng_test.go Normal file
View File

@@ -0,0 +1,38 @@
package rng
import "testing"
func TestDeterministic(t *testing.T) {
a := FromInt(42).New()
b := FromInt(42).New()
for i := 0; i < 1000; i++ {
if x, y := a.Uint64(), b.Uint64(); x != y {
t.Fatalf("sekvensen skiljer sig vid %d: %d != %d", i, x, y)
}
}
}
func TestDifferentSeedsDiffer(t *testing.T) {
if FromInt(1).New().Uint64() == FromInt(2).New().Uint64() {
t.Fatal("olika seeds gav samma första värde")
}
}
func TestDeriveIsStableAndContextual(t *testing.T) {
s := FromInt(7)
if s.Derive("ticket") != s.Derive("ticket") {
t.Fatal("Derive är inte deterministisk")
}
if s.Derive("ticket") == s.Derive("toilet") {
t.Fatal("olika kontexter gav samma del-seed")
}
if s.Derive("ticket") == FromInt(8).Derive("ticket") {
t.Fatal("olika rot-seeds gav samma del-seed")
}
}
func TestNewSeedIsRandom(t *testing.T) {
if NewSeed() == NewSeed() {
t.Fatal("två NewSeed() gav samma värde")
}
}