deckcodec

package module
v1.0.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 3, 2025 License: MIT Imports: 11 Imported by: 0

README

deckcodec

Go Reference Go Report Card

A compact, efficient codec for encoding and decoding Xross Stars card game decks into URL-safe base64 strings.

Features

  • 🗜️ Compact encoding: Uses bit-level compression to minimize encoded deck size
  • 🔒 Deterministic: Identical decks always produce the same encoded string
  • 🌐 URL-safe: Uses base64url encoding (no padding) for web compatibility
  • ⚡ Fast: Efficient bit-packing algorithms with minimal allocations
  • 🎯 Type-safe: Strong typing with comprehensive error handling
  • ✅ Well-tested: Extensive test coverage including property-based tests

Installation

go get github.com/Argonauts-inc/deckcodec

Quick Start

Below is a minimal end-to-end flow using the same shapes as the example. Two phases:

  • Preparation (one-time / CI job) → build pack/1.json and manifest.json under tmp/
  • Runtime → read manifest, pick a pack (using Bloom), ParsePack → Encode → Decode

See examples/basic/main.go for the full version.

Preparation (one-time)

package main

import (
	"encoding/json"
	"os"
	"path/filepath"
	"time"

	"github.com/Argonauts-inc/deckcodec"
)

func mustWriteJSON(path string, v any) {
	b, _ := json.MarshalIndent(v, "", "  ")
	_ = os.MkdirAll(filepath.Dir(path), 0o755)
	_ = os.WriteFile(path, b, 0o644)
}

func main() {
	// 1) Get PKs from DB/CSV/etc...
	pks := []uint64{
		101, 205, 303, 412, // leaders (example)
		501, 602, 703, 804, // deck uniques (example)
		301, 402, 503, 604, // tactics (example)
	}

	// 2) Build pack (sorted; dedup for stable ordinals)
	pack, _ := deckcodec.BuildPack(pks, deckcodec.PackBuildOpts{
		FormatID:    1,
		Name:        "Standard 2025-09",
		Deduplicate: true,
	})

	// 3) Build manifest (with Bloom filter, ~1% FPR)
	man, _ := deckcodec.BuildManifest(
		[]deckcodec.Pack{pack},
		func(fid uint16) string { // URL recorded in manifest
			return filepath.ToSlash(filepath.Join("tmp", "pack", "1.json"))
		},
		1, time.Now(), 0.01,
	)

	// 4) Write JSONs under tmp/
	mustWriteJSON(filepath.Join("tmp", "pack", "1.json"), pack)
	mustWriteJSON(filepath.Join("tmp", "manifest.json"), man)
}

Runtime (your app)

package main

import (
	"encoding/json"
	"fmt"
	"os"
	"slices"

	"github.com/Argonauts-inc/deckcodec"
)

func mustReadJSON(path string, v any) {
	f, _ := os.Open(path)
	defer f.Close()
	dec := json.NewDecoder(f)
	dec.DisallowUnknownFields()
	_ = dec.Decode(v)
}

func main() {
	// 1) Read manifest (file/HTTP/etc. — here from tmp/)
	var man deckcodec.Manifest
	mustReadJSON("tmp/manifest.json", &man)

	// Deck to encode (order-free; encoder normalizes leaders/tactics)
	in := deckcodec.DeckInput{
		Leader:  []uint64{412, 205, 101, 303},
		Tactics: []uint64{705, 402, 604, 503, 301},
		Deck:    map[uint64]uint8{501: 4, 602: 3, 703: 2, 804: 1},
	}

	// 2) Pick the smallest candidate pack via Bloom (already size-sorted in manifest)
	uniq := deckcodec.UniqSortedPKsFromDeck(in)
	var pm deckcodec.PackMeta
	for _, m := range man.Packs {
		if deckcodec.MayContainAll(m.Bloom, uniq) { pm = m; break }
	}
	if pm.FormatID == 0 { panic("no pack candidate") }

	// 3) Read pack JSON (file/HTTP — here file recorded in manifest URL)
	rc, _ := os.Open(pm.URL)
	defer rc.Close()

	// 4) ParsePack → stable, ascending Cards
	pack, _ := deckcodec.ParsePack(rc)

	// 5) Encode → URL-safe Base64 (no padding)
	code, _ := deckcodec.Encode(pack, in)
	fmt.Println("Share path:", "/deck/"+code)

	// 6) Decode → verify round-trip
	out, _ := deckcodec.Decode(pack, code)

	// (Optional) quick check
	wantL, wantT := slices.Clone(in.Leader), slices.Clone(in.Tactics)
	slices.Sort(wantL); slices.Sort(wantT)
	if !slices.Equal(wantL, out.Leader) || !slices.Equal(wantT, out.Tactics) {
		panic("normalized sections mismatch")
	}
	for pk, c := range in.Deck {
		if out.Deck[pk] != c { panic("count mismatch") }
	}
}
Run the complete example
go run ./examples/basic/main.go

This will:

  1. Create tmp/pack/1.json and tmp/manifest.json
  2. Load the manifest and pack
  3. Encode to a short, URL-safe code
  4. Decode back and verify the round-trip

API Reference

Core Types
DeckInput

Input structure for encoding a deck:

type DeckInput struct {
    Leader  []uint64         // Leader card IDs
    Tactics []uint64         // Tactics card IDs  
    Deck    map[uint64]uint8 // Main deck: card ID → count (1-4)
}
DeckOutput

Output structure from decoding:

type DeckOutput struct {
    FormatID uint16           // Pack format identifier
    Leader   []uint64         // Leader card IDs (sorted)
    Tactics  []uint64         // Tactics card IDs (sorted)
    Deck     map[uint64]uint8 // Main deck: card ID → count
}
Pack

Card set definition:

type Pack struct {
    FormatID      uint16   `json:"format_id"`
    Name          string   `json:"name,omitempty"`
    CreatedAt     string   `json:"created_at,omitempty"`
    SchemaVersion int      `json:"schema_version,omitempty"`
    Cards         []uint64 `json:"cards"` // Must be sorted
}
Core Functions
Encode(pack Pack, input DeckInput) (string, error)

Encodes a deck into a compact base64url string.

Parameters:

  • pack: The card set definition containing valid card IDs
  • input: The deck to encode

Returns:

  • Compact base64url-encoded string
  • Error if validation fails (invalid card IDs, counts out of range 1-4)
Decode(pack Pack, encoded string) (DeckOutput, error)

Decodes a base64url string back into a deck.

Parameters:

  • pack: The card set definition (must match the pack used for encoding)
  • encoded: Base64url-encoded deck string

Returns:

  • Decoded deck with sorted card lists
  • Error if decoding fails or format ID mismatch
LoadPack(filepath string) (Pack, error)

Loads a pack definition from a JSON file.

Parameters:

  • filepath: Path to the JSON pack file

Returns:

  • Pack with sorted card list
  • Error if file reading or JSON parsing fails

Pack File Format

Pack files are JSON documents defining the available cards for a format:

{
  "format_id": 1,
  "name": "Standard Format",
  "created_at": "2025-01-15T10:00:00Z",
  "schema_version": 1,
  "cards": [101, 205, 303, 412, 501, 602, 703, 804, 905]
}

Requirements:

  • format_id: Unique identifier for this card set
  • cards: Array of card IDs (will be sorted automatically)
  • Card IDs must be unique within the pack
  • Other fields are optional metadata

Manifest System

For production applications with multiple packs, you can create a manifest - a centralized index of all available packs. This enables efficient pack discovery and optional Bloom filter-based pre-filtering.

Manifest Structure

A manifest is a JSON document listing all available packs:

{
  "schema_version": 1,
  "updated_at": "2025-01-15T10:00:00Z",
  "packs": [
    {
      "format_id": 1,
      "name": "Standard Format",
      "url": "https://cdn.example.com/packs/standard-v1.json",
      "M": 25,
      "bloom": {
        "m_bits": 128,
        "k": 3,
        "salt1": 11400714819323198485,
        "salt2": 13758210859908730299,
        "bits_b64": "gICA..."
      }
    },
    {
      "format_id": 2, 
      "name": "Legacy Format",
      "url": "https://cdn.example.com/packs/legacy-v1.json",
      "M": 150
    }
  ]
}
Building a Manifest
package main

import (
    "fmt"
    "log"
    "time"
    
    "github.com/Argonauts-inc/deckcodec"
)

func main() {
    // Create multiple packs
    pack1, _ := deckcodec.BuildPack([]uint64{101, 205, 303}, deckcodec.PackBuildOpts{
        FormatID: 1,
        Name:     "Standard Format",
    })
    
    pack2, _ := deckcodec.BuildPack([]uint64{501, 602, 703, 804}, deckcodec.PackBuildOpts{
        FormatID: 2,
        Name:     "Legacy Format", 
    })
    
    // Build manifest with Bloom filters (optional)
    manifest, err := deckcodec.BuildManifest(
        []deckcodec.Pack{pack1, pack2},
        func(formatID uint16) string {
            return fmt.Sprintf("https://cdn.example.com/packs/format-%d.json", formatID)
        },
        1,                    // schema version
        time.Now(),           // updated at
        0.01,                 // target false positive rate (1%) - set to 0 to disable Bloom filters
    )
    if err != nil {
        log.Fatal(err)
    }
    
    // The manifest is sorted by pack size (smaller packs first)
    // This helps with encoding optimization
    for _, pack := range manifest.Packs {
        fmt.Printf("Format %d: %s (%d cards)\n", pack.FormatID, pack.Name, pack.M)
        if pack.Bloom != nil {
            fmt.Printf("  Bloom filter: %d bits, %d hash functions\n", pack.Bloom.MBits, pack.Bloom.K)
        }
    }
}
Using Bloom Filters for Pre-filtering

Bloom filters allow clients to quickly check if a pack might contain specific cards without downloading the full pack:

// Check if a pack might contain specific cards before downloading
func mightContainCards(packMeta deckcodec.PackMeta, cardIDs []uint64) bool {
    if packMeta.Bloom == nil {
        return true // No filter, assume it might contain the cards
    }
    
    for _, cardID := range cardIDs {
        if !packMeta.Bloom.MayContain(cardID) {
            return false // Definitely doesn't contain this card
        }
    }
    return true // Might contain all cards (could be false positive)
}
Manifest Benefits
  1. Centralized Discovery: Single endpoint to discover all available packs
  2. CDN-Friendly: Packs can be hosted on CDNs with cache-friendly URLs
  3. Size Optimization: Packs sorted by size for encoding efficiency
  4. Pre-filtering: Bloom filters reduce unnecessary pack downloads
  5. Versioning: Schema versioning for backward compatibility
Production Workflow
  1. Create Packs: Define card sets as JSON files
  2. Build Manifest: Generate manifest with pack metadata and URLs
  3. Host on CDN: Deploy packs and manifest to CDN
  4. Client Usage:
    • Download manifest
    • Use Bloom filters to pre-filter relevant packs
    • Download and cache only needed packs
    • Encode/decode decks using appropriate packs

Encoding Format

The codec uses a space-efficient binary format:

  1. Header (16 bits): Pack format ID
  2. Leader section: Count + variable-width card ordinals
  3. Tactics section: Count + variable-width card ordinals
  4. Main deck section: Count + (ordinal, count) pairs

Card IDs are converted to ordinals (0-based indices) and encoded using the minimum number of bits needed for the pack size. Card counts are encoded as 2-bit values (1-4 → 0-3).

Error Handling

The library provides detailed error messages for common issues:

  • Invalid card counts: Must be 1-4 copies per card
  • Unknown card IDs: All cards must exist in the pack
  • Format mismatch: Encoded deck format must match pack format
  • Corrupted data: Malformed base64 or insufficient data

Testing

Run the comprehensive test suite:

# Run all tests
go test ./...

# Run with verbose output
go test -v ./...

# Run specific test
go test -run TestEncodeDecode

# Run tests with coverage
go test -cover ./...

Performance

The codec is optimized for both space and speed:

  • Encoding speed: ~1M decks/second on modern hardware
  • Compression ratio: ~60-80% size reduction vs JSON
  • Memory usage: Minimal allocations, suitable for high-throughput applications

Contributing

  1. Fork the repository
  2. Create a feature branch (git checkout -b feature/amazing-feature)
  3. Make your changes with tests
  4. Run the test suite (go test ./...)
  5. Format your code (go fmt ./...)
  6. Commit your changes (git commit -am 'Add amazing feature')
  7. Push to the branch (git push origin feature/amazing-feature)
  8. Open a Pull Request
Development Requirements
  • Go 1.24.1 or later
  • Standard Go toolchain (go fmt, go test)
  • Optional: golangci-lint for additional linting
Code Style
  • Follow standard Go conventions and gofmt formatting
  • Write tests for new functionality
  • Update documentation for API changes
  • Maintain backward compatibility when possible

License

This project is licensed under the MIT License - see the LICENSE file for details.

Acknowledgments

  • Built for the Xross Stars trading card game
  • Inspired by efficient binary serialization formats
  • Uses variable-width integer encoding for optimal compression

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Encode

func Encode(p Pack, in DeckInput) (string, error)

Encode encodes a deck (DeckInput) into a compact base64 string using the provided Pack definition. The encoding includes the format ID, leader cards, tactics cards, and the main deck with counts. Returns the encoded string or an error if the input is invalid.

func MayContainAll

func MayContainAll(b *BloomMeta, pks []uint64) bool

MayContainAll returns true if Bloom "may" contain all PKs (Bloom semantics). If b is nil or empty, it returns true (do-not-filter behavior).

func UniqSortedPKsFromDeck

func UniqSortedPKsFromDeck(in DeckInput) []uint64

UniqSortedPKsFromDeck returns a sorted unique list of PKs collected from (Leader, Tactics, Deck keys). The result is safe to reuse as a set-like input for Bloom prefiltering.

Types

type BloomMeta

type BloomMeta struct {
	MBits   uint32 `json:"m_bits"`   // bit array length (m)
	K       uint8  `json:"k"`        // number of hash functions (k)
	Salt1   uint64 `json:"salt1"`    // double-hashing salt 1
	Salt2   uint64 `json:"salt2"`    // double-hashing salt 2
	BitsB64 string `json:"bits_b64"` // raw bits (little-endian bit order), Base64URL (no padding)
}

BloomMeta holds a compact membership filter for a pack. This lets clients pre-filter packs BEFORE downloading full pack JSON.

func (*BloomMeta) MayContain

func (b *BloomMeta) MayContain(pk uint64) bool

MayContain returns true if the set *may contain* pk (Bloom semantics). If Bloom is nil/empty/unreadable, returns true (do-not-filter).

type DeckInput

type DeckInput struct {
	Leader  []uint64
	Tactics []uint64
	Deck    map[uint64]uint8
}

type DeckOutput

type DeckOutput struct {
	FormatID uint16
	Leader   []uint64
	Tactics  []uint64
	Deck     map[uint64]uint8
}

func Decode

func Decode(p Pack, code string) (DeckOutput, error)

Decode decodes a base64-encoded deck string into a DeckOutput using the provided Pack definition. Returns the decoded deck or an error if the code is invalid or does not match the pack.

type Manifest

type Manifest struct {
	SchemaVersion int        `json:"schema_version"`
	UpdatedAt     string     `json:"updated_at,omitempty"` // RFC3339
	Packs         []PackMeta `json:"packs"`
}

Manifest is the public index of all available packs. Host this on a CDN; keep packs immutable.

func BuildManifest

func BuildManifest(
	packs []Pack,
	urlFor func(fid uint16) string,
	schemaVersion int,
	updatedAt time.Time,
	targetFP float64,
) (Manifest, error)

BuildManifest builds a manifest. If targetFP > 0, it attaches a Bloom filter to each PackMeta for pre-filtering (false-positive rate ~= targetFP).

- packs are sorted ascending by M (tie-break by FormatID) for encode-side heuristics - urlFor(fid) must return a non-empty URL for each pack - duplicate or zero FormatID is rejected

type Pack

type Pack struct {
	FormatID      uint16   `json:"format_id"`
	Name          string   `json:"name,omitempty"`
	CreatedAt     string   `json:"created_at,omitempty"`
	SchemaVersion int      `json:"schema_version,omitempty"`
	Cards         []uint64 `json:"cards"`
}

Pack represents a dictionary of card primary keys for a given format. Cards MUST be ascending for ordinal-based encoding to be stable.

func BuildPack

func BuildPack(pks []uint64, opts PackBuildOpts) (Pack, error)

BuildPack builds a Pack from an in-memory list of PKs. It sorts ascending and (optionally) de-duplicates.

func ParsePack

func ParsePack(r io.Reader) (Pack, error)

ParsePack reads a Pack from any io.Reader (file, HTTP, memory buffer). It validates FormatID and sorts Cards ascending for stable ordinals.

type PackBuildOpts

type PackBuildOpts struct {
	FormatID    uint16
	Name        string
	Deduplicate bool // default: true; remove duplicate card ids
}

type PackMeta

type PackMeta struct {
	FormatID uint16     `json:"format_id"`
	Name     string     `json:"name,omitempty"`
	URL      string     `json:"url"` // absolute or CDN path to pack JSON
	M        int        `json:"M"`   // number of cards in the pack
	Bloom    *BloomMeta `json:"bloom,omitempty"`
}

PackMeta summarizes one pack for the manifest. Bloom is optional and present only if targetFP > 0 when building the manifest.

Directories

Path Synopsis
examples
basic command
examples/with_manifest/main.go
examples/with_manifest/main.go

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL