Skip to content

CampaignSummary carries only a titleKey, and no session-free way to resolve it, so campaign selection cannot render #287

Description

@The-Running-Dev

Campaign titles cannot be resolved before a session exists, so the selection screen shows raw keys

Picking a campaign is the first thing a player does, and it is the one screen no client can
render properly. CampaignSummary carries a titleKey, and the only way to turn a key into
text is getStrings(sessionId) — but selection happens before a session exists. Every
client is left choosing between printing the key and reaching past the store for the string.

Reproduced on all three surfaces:

  • Text client. npm run demo from src/engine/ prints the key where the title belongs:

    [bulgaria-bureaucracy] bureaucracy.campaign.title (story-graph)
    [stable-life] stable-life.campaign.title (simulation)
    

    renderCampaignList (src/engine/src/clients/text/render.ts:87) already documents this
    in a comment, and client.test.ts's first case is named
    "listCampaigns — returns the real campaign, unresolved titleKey (no session yet)" — the
    gap is asserted as correct behaviour today.

  • MCP. list_campaigns (src/engine/src/mcp/server.ts:54) has the identical problem and
    is arguably worse off: get_strings is the only resolution tool and it requires a
    sessionId, so an agent must either string-match the key or call start_game — a
    side-effecting command — purely to read a title. server.test.ts:39 asserts the raw key.

  • Browser demo. site/src/play/composition.ts:158 resolves it by reaching into the
    registry directly (registry.value!.strings.get(built.campaign.titleKey)), which is exactly
    the boundary a client is not supposed to cross.

This is the same class as
#278: a projection that
omits what a client needs, forcing the client to reach past it into content or registry. The
rule it erodes is 09-clients.md §2 — a client is a projection of the session store, never a
participant
. Two different clients have now worked around it two different ways, which is
what makes it a contract question rather than a rendering bug.

This needs a signature decision and is deliberately not being made here. Two candidate
shapes, both viable:

  1. CampaignSummary carries a resolved title: string, resolved by the store at listing
    time. Keeps the surface at ten operations and ten MCP tools — the one-to-one count
    09-clients.md §4 makes structural, and which §4 explicitly declines to add an eleventh
    row to. The cost is that it puts resolved text inside a projection DTO, which
    20-contract.md §7 rejected once already when it chose getStrings over resolving inside
    the DTOs, on the grounds that it bakes a locale into the projection.
  2. A session-free string-resolution operation on SessionStore. Consistent with the
    existing "clients never string-match, they resolve keys" model, and cheap in
    implementation: getStrings (src/engine/src/core/session/store.ts:351) already returns
    the whole frozen registry table and uses its sessionId only as an existence check — the
    session scoping is a validation, not a partition. The cost is the eleventh operation, and
    the ten/ten counting property stops being the checklist it currently is.

Choosing between them is /contract's call, not an implementation detail. Recorded here so
whichever way it goes, the reasoning lands in design/20-contract.md rather than in a client.

Done when

  • The contract owner has chosen between the two shapes (or a third), and the choice with
    its reasoning is recorded in design/20-contract.md §7.
  • A client can render the campaign-selection screen with human-readable titles calling
    nothing but SessionStore.
  • The MCP list_campaigns surface resolves the same way, with no start_game call needed
    to read a title.
  • 09-clients.md §4's coverage checklist is reconciled with the outcome — either the
    ten/ten count still holds, or the document says why it no longer does.
  • The text-client and MCP tests that currently assert an unresolved titleKey are updated
    to assert the chosen behaviour.
  • The browser demo's direct registry read (site/src/play/composition.ts:158) is retired
    in favour of the store operation.

Agent instructions

Run /contract. This is a signature decision, not a bug fix — do not change a signature
before the shape is chosen.

  • Authority: design/20-contract.md §7 (SessionStore, CampaignSummary, and the
    Why getStrings is a store operation note) @ 8e3cec0
  • Client rule at stake: 09-clients.md §2 (the only surface) and §4 (the API coverage
    checklist, and its explicit refusal to add an eleventh operation)
  • Same class: #278
    — a projection omitting a field, forcing clients past the boundary
  • Provenance: design/90-decisions.md §2, Found by the first downstream host —
    SubZeroDev.Adventures
    . Found the same way: by building, not by review
  • Current code: src/engine/src/core/session/types.ts:29 (CampaignSummary), :139
    (getStrings), src/engine/src/core/session/store.ts:331 and :351 (both
    implementations), src/engine/src/clients/text/render.ts:87,
    src/engine/src/mcp/server.ts:54
  • Out of scope here: localization beyond the single MVP locale; VisibleStat omits the declared range, so clients read Campaign.content to get it #278's VisibleStat
    range, which is the same class but a separate decision

Stop if the chosen shape turns out to require changing getStrings' existing signature or
the StringTable type — that widens this past campaign selection and should be decided as its
own item.

Metadata

Metadata

Assignees

No one assigned

    Labels

    openOpen work tracked from the canonical design register

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions