Skip to content

feat(engine): lay out a card's rules text as sections of rows - #134

Open
snyderks wants to merge 1 commit into
odevine:mainfrom
snyderks:feat/card-sections
Open

snyderks wants to merge 1 commit into
odevine:mainfrom
snyderks:feat/card-sections

Conversation

@snyderks

Copy link
Copy Markdown
Contributor

Summary

A layout can now declare a section, a region whose rules text the engine splits into rows, one for each loyalty ability, saga chapter, or class level, spread top to bottom and shrunk together until every row fits. A row can carry a badge or number beside it, such as a loyalty cost or a chapter's numeral, and template.PlacedRow.FirstLineCenter anchors that badge on the row's own opening line instead of the center of however many lines it wraps to.

Supporting additions, all needed by templates this makes possible:

  • template.TextBoxSpec.Prefix (and manifest.TextStyle.Prefix) draws literal text before a box's own value, such as the classic frame's "Illus. " artist credit in place of the modern frame's nib icon.
  • An art window's after is now optional, so a layout can place its art beneath the whole stack.
  • frame.Condition gains loyalty, chaos, snow and nonsnow features and a count comparison such as rows>3 for a condition that depends on how many rows a section has.
  • card.flipsideType now reads the other face's own single subtype ("Tibalt", "God") instead of always its supertype ("Planeswalker", "Creature").

Manifest contract raised to 1.2, which the doc comment already promises only adds optional fields, so this is not a breaking change.

This is the first of a planned sequence of PRs that together bring in a batch of Proxyshop-converted template families (M15, Classic, Planeswalker Borderless). This PR has no dependency on the others; the next PR (the psdconvert/tplmerge conversion tooling) does depend on the manifest/validate changes here.

Test plan

  • gofmt -l . clean in engine/
  • go vet ./... clean in engine/
  • go test -race ./... passing in engine/
  • Verified in an isolated worktree against bare origin/main, with none of the other in-flight work mixed in

🤖 Generated with Claude Code

https://claude.ai/code/session_01XRUBuanCoioaJx1YjULfbQ

A layout can now declare a section, a region whose rules text the engine
splits into rows, one for each loyalty ability, saga chapter, or class
level, spread top to bottom and shrunk together until every row fits.
A row can carry a badge or number beside it, such as a loyalty cost or
a chapter's numeral, and template.PlacedRow.FirstLineCenter anchors
that badge on the row's own opening line instead of the center of
however many lines it wraps to. Manifest contract raised to 1.2, which
only adds optional fields.

Supporting additions: template.TextBoxSpec.Prefix (and
manifest.TextStyle.Prefix) draws literal text before a box's own
value, such as the classic frame's "Illus. " artist credit in place of
the modern frame's nib icon. An art window's "after" is now optional,
so a layout can place its art beneath the whole stack. frame.Condition
gains loyalty, chaos, snow and nonsnow features and a count comparison
such as "rows>3" for a condition that depends on how many rows a
section has. card.flipsideType now reads the other face's own single
subtype ("Tibalt", "God") instead of always its supertype
("Planeswalker", "Creature").

gofmt -l ., go vet ./... and go test -race ./... all clean in engine/.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRUBuanCoioaJx1YjULfbQ
snyderks added a commit to snyderks/mimic that referenced this pull request Oct 16, 2026
…date the bridge golden

engine/frame.Condition now accepts a count comparison such as "rows>3"
alongside its plain feature vocabulary (odevine#134). The studio's
own vocabulary test compiled every condition name on its own and failed
on the new count names, which only compile with a comparison after
them; it now tries that form too before failing.

card.Data and the types built from it gained a Defense field for a
battle card's defense stat, which crosses the bridge, so
bridge.golden needed regenerating (go test ./internal/desktop -run
Bridge -update).

No functional change: the app already renders a battle's defense and
accepts count conditions correctly, since both come from the engine.
This just keeps the studio's own tests and the bridge golden in step
with it. Depends on odevine#134 landing in a released engine;
go.work (not committed) stands in for that pin locally. go test ./...
passes in ui/ with that in place.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XRUBuanCoioaJx1YjULfbQ
Comment thread engine/author/schema.go
},
reflect.TypeOf(manifest.RowItem{}): {
"for": {"enum": append([]string{""}, card.RowFields()...), "description": "The row field the item shows. Empty draws for every row"},
"anchor": {"enum": []string{"", "center", "top"}},

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The anchor enum leaves out first-line, which the validator (manifest/sections.go) and placeSection both accept and which this PR introduces. A schema-driven editor or lint would reject the new anchor. Add it to the enum and cover it with a test in author.

Comment thread engine/render/render.go
nodes = append(nodes, got...)
}
}
for _, ps := range placed {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Section text and images skip the controls that regular boxes honor. Boxes check req.Hides("text", key), call req.Report and check ctx.Err() per job (see the loop just above), and none of that happens here or for sectionImages. ui drives Hide (pipeline, studio), so a hide toggle cannot affect a section's rows. A new hide kind such as "section" (or reusing text with the abilities.rows[1] keys that Describe already reports) would close the gap.

Comment thread engine/render/section.go

// placeSection splits the face's rules text into the section's rows, lays them
// out, and lists what each row draws
func placeSection(req template.RenderRequest, t *template.Template, layout *template.Layout, authored image.Point, job sectionJob, fc *faces, taken card.Taken, syms symbols, fr *fonts.Resolver, scale template.Scale) (*placedSection, error) {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Row text is drawn through sectionPiece.textNode and RenderTextBox directly instead of placeBox/drawBox, so box features (ClearOf, Space, Rotate, Flow) do not apply inside a section, and manifest.Section.Text accepts a TextStyle that may imply otherwise. If that is intended, say so in the Section doc comment. Otherwise consider validating that unsupported fields are unset, or routing rows through the box pipeline.

}
// The text keeps out of the rectangle where each row landed without it, so a
// row beside it wraps narrower and the rows settle again
tops := make([]int, len(out.Rows))

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The avoid pass is a single iteration. Rows are laid out once without the avoid rectangle, then re-measured with each row pinned to its first-pass top (tops). When avoidance rewraps a row and changes its height, later rows move, but they are measured against the stale tops, so a row can end up beside or outside the area it was measured for. It converges for the fixtures, but a short loop to a fixed point (or a comment on the limitation) would make it dependable. A test with an avoid rectangle that changes a row's height would show it.

Comment thread engine/card/sections.go
type Row struct {
// Cost is what the row is activated for: a loyalty ability's cost such as
// "+1" or "−3", or the mana a class level costs
Cost string

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cost holds both a loyalty ability's cost and a class level's mana cost, and keyField (render/section.go) has to special-case which field marks a row. It works, but a separate field or an explicit accessor per split would keep the two meanings from leaking into templates (for: "cost" means different things for loyalty and levels).

Comment thread engine/card/sections.go
// supertype or type otherwise ("Creature", "Sorcery"). A card can print more
// than one subtype, and the rule about which single word to show then does
// not generalize cleanly, so this keeps to the common case rather than guess
func flipsideType(d *Data) string {

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flipsideType now prefers the other face's single subtype over its type, which changes output for existing MDFC templates (for example a Creature face now prints its one subtype rather than "Creature"). The behavior change is in the description but deserves its own test against an existing template's expectations, and possibly a separate box name such as flipside_subtype so current layouts do not shift.

Comment thread engine/manifest/decode.go
// MaxDimension is the largest width, height, or position in a canvas
MaxDimension = 20000
MaxParts = 256
MaxParts = 1024

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MaxParts goes from 256 to 1024 without a note. Say what template needs it. This is also part of a wider scope: the PR adds a leveler parser, prototype/mutate/chaos boxes, defense, SlotPrototype, Keys.Snow/Prototype and the boxFields→textStyle refactor, none of which the title or summary mention. Splitting these or listing them in the description would make this easier to review.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants