Files
fool-cardgame/docs/DECISIONS.md
T
ftcandClaude Opus 5 7fbe4c324d Игра запускается и играется: три рантайм-бага и адаптивная вёрстка
Проверено вживую в Chrome: лобби -> матч -> бот ходит -> результат ->
статистика в лобби. Полный матч отыгран, оба исхода видел.

Баги, которые собирались и типизировались, но падали только в рантайме:

1. Canvas не менял размер под экран. Своего cc.Widget у него не было,
   а alignCanvasWithScreen правит только камеру. Canvas оставался
   1280x720, и все растянутые по нему дети — тоже.
2. [...map.values()] превращается сборкой в [].concat(map.values()),
   а concat не разворачивает итератор, а кладёт его одним элементом.
   Отсюда "view.cancelAnimations is not a function" на первой же уборке
   карт. Теперь Array.from.
3. Рубашка козыря рисовалась поверх колоды, а не под ней.

Вёрстка переделана под требование UI.md (4:3 .. 21:9):
- fitWidth даёт постоянную ширину, но не высоту: на 4:3 видно только
  ±720 юнитов. Кнопка "Leave" на y=840 и низ руки на -860 были за
  экраном именно там, где это важнее всего
- рука и панель действий считаются от нижней границы экрана,
  "Leave" прибит виджетом к левому верхнему углу
- стол — один ряд до шести карт вместо двух рядов по три: экономит
  ~215 юнитов высоты, которых на 4:3 нет. Ряд центрируется по тем
  картам, что реально на столе
- затемнение под окном результата рисуется непрозрачным спрайтом:
  тонировать и без того полупрозрачный спрайт почти ничего не даёт

Инструменты:
- npm run serve — отдаёт сборку и роут /_aspects: игра в трёх
  фиксированных iframe-ах 4:3 / 16:9 / 21:9. Движок подгоняет себя
  под окно, поэтому iframe — единственный простой способ увидеть
  вёрстку в форме, которой окно не имеет
- npm run check:spreads — ловит спред неитерируемого в массив
- npm run check — чистота core, спреды, сцены, типы, тесты

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 12:41:47 +03:00

205 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Decision log
Choices made while implementing the specs, recorded so they can be challenged
later. Each entry states what the specs left open, what was chosen and why.
Format: `YYYY-MM-DD` · area · decision.
---
## 2026-09-13 · Rules · Turn passes *past* a defender who took
GAMEPLAY.md says "after drawing cards move is passed to next player in clockwise
order", but does not say what happens when the defender took the cards.
**Decision.** The defender who took is skipped: the next attacker is the player
after the defender. In 1v1 this means the same player attacks again — the
standard rule, and the one that makes taking a real cost.
## 2026-09-13 · Rules · Interpretation of the add-card limits
GAMEPLAY.md gives three limits on attacking cards. The second one ("there should
not be more attacking cards than card amount in defending player's hand") is
ambiguous about *when* it is measured.
**Decision.** A card may be added only if all three hold at the moment of adding:
1. `table.length < 6` (`< 5` on the very first turn of the game);
2. `unbeatenCount < defenderHandSize` — where `unbeatenCount` counts attack cards
with no defence on them, and `defenderHandSize` is the defender's hand *at that
moment*. Once the defender has declared a take, their hand no longer shrinks,
so this freezes naturally;
3. its rank matches the rank of some card already on the table, attack or
defence alike.
The first attacking card of a turn is exempt from (3) — there is nothing on the
table to match.
## 2026-09-13 · Rules · Attack order is serialised
In a real game attackers may throw cards in at any moment, concurrently with the
defender thinking. Modelling genuine concurrency buys nothing for 1v1 against a
bot and makes the state machine much harder to test.
**Decision.** The core is a strict turn-based state machine with an explicit
"who may act now" set. Attackers are polled for additions only when there are no
unbeaten cards on the table, or after a take has been declared. This is
behaviourally identical for 1v1 and extends cleanly to N players.
## 2026-09-13 · Architecture · Rules core is engine-independent
**Decision.** Everything under `assets/scripts/core/` is plain TypeScript with no
`cc` import, no `Component`, no scene access, and no wall-clock time. The state
machine is a pure reducer `apply(state, action) -> { state, events }` over a
seeded RNG.
**Why.** It makes the entire rule set verifiable with `npm test` in a second, on
any machine, with no editor running — which is the only practical way to build
this unattended. It also leaves the door open for server-authoritative
multiplayer later without a rewrite.
## 2026-09-13 · Architecture · Bots consume a restricted `PlayerView`
**Decision.** A bot never receives `GameState`. It receives a `PlayerView`: its
own hand, the table, the trump card, counts (deck size, each opponent's hand
size) and the discard count.
**Why.** It makes cheating structurally impossible rather than a matter of
discipline, and a `PlayerView` is exactly the payload a future network protocol
would send to a client.
## 2026-09-13 · Architecture · Savegame sections are self-registering
ARCHITECTURE.md asks for a save composed of independent classes, keyed by class
name.
**Decision.** Sections implement `ISaveSection { readonly key; save(); load(data) }`
and register with `SaveManager`. The key is an explicit string constant, *not*
`constructor.name`, because minification in release builds renames classes and
would silently orphan every existing save.
## 2026-09-13 · Presentation · 1080×1920 design resolution, fit-width
UI.md requires portrait, 4:3 to 21:9. ART.md fixes the art budget at 1080 px wide.
**Decision.** Design resolution 1080×1920 with `fitWidth = true`,
`fitHeight = false`, so the width is always exactly 1080 design units and taller
screens simply reveal more vertical space. Layout is anchored with widgets to
top/bottom, the play area centred, and the safe-area inset respected on the top
and bottom bars.
## 2026-09-13 · Art · Vector deck from `local/`, licence recorded
`local/Playing Cards` holds the Chris Aguilar "Vector Playing Cards" set
(also mirrored on Wikimedia Commons), which is **LGPL-3.0**.
**Decision.** Use the 36 faces needed for a durak deck, rendered from the SVG
sources so the downscale to the 1080-wide budget stays sharp. Attribution and
licence text are recorded in `assets/resources/cards/ATTRIBUTION.md`. The card back,
table surface and UI chrome are not in that set and are authored for this project.
## 2026-09-13 · Presentation · The screen renders state, it does not replay events
The rules emit an event stream, which invites an animation queue that replays it.
**Decision.** `GameScreen.render(state)` lays the whole table out from the current
state and tweens every card node to its new place. A card node stays bound to its
`Card` for as long as that card is visible.
**Why.** "The six of clubs moved from my hand to the table" then needs no special
case — it is the same node arriving at a new position. An event-driven queue has
to choreograph every transition by hand and desynchronises the moment one case is
missed. The event stream is still there for sound and for one-off flourishes.
## 2026-09-13 · Presentation · Card art lives in the `resources` bundle
The first attempt gave `CardLibrary` a `SpriteAtlas` reference to the auto-atlas
(`cards.pac`) and asked it for frames by name. The build came out with no card
textures at all.
**Decision.** The 36 faces and the back live in `assets/resources/cards` and are
loaded with `resources.loadDir`. `Bootstrap` preloads the folder in the loader
scene, so the lobby never waits.
**Why.** A `.pac` is a build-time packing instruction, not a runtime asset: it
packs whatever sprite frames the build already pulled in, and it cannot be loaded
and queried by name. Nothing referenced the individual frames, so nothing was
packed. The `.pac` still earns its keep — all 37 sprites end up in one 2048²
texture, so the whole table costs one draw call.
## 2026-09-13 · Presentation · Button clicks are wired in code
**Decision.** Buttons are exposed as `@property(Button)` and subscribed to in
`onLoad` rather than through the scene's `clickEvents` list.
**Why.** The wiring stays visible in the source, and renaming a handler becomes a
compile error instead of a button that silently stops working. (The editor tool
server also cannot write array-typed properties, which is how `clickEvents` is
stored — so this was the only option available to automation anyway.)
## 2026-09-13 · Presentation · Interface text is English, collected in one file
**Decision.** Every player-visible string is in
`assets/scripts/ui/common/Strings.ts`, in English.
**Why.** The specs, the code and the docs are all in English, and localisation is
out of scope for iteration 1. Keeping the strings together means switching
language — or adding one — is a change to that one file rather than a hunt
through scenes and components. Worth revisiting before release, given the game
is *durak*.
## 2026-09-13 · Presentation · One tap is the whole interaction
**Decision.** Tapping a card plays it. When defending, the attack being answered
is worked out from the card rather than selected separately. The logic lives in
`core/rules/intent.ts`, not in the component.
**Why.** Select-then-target doubles the taps for no gain, because the serialised
rules leave only one unanswered attack at a time. Putting it in the core means
the one piece of interface logic that can silently reject a legal move is covered
by headless tests, including a property test that it never proposes an action the
rules would refuse.
## 2026-09-13 · Presentation · The loader reports every step it takes
**Decision.** `Bootstrap` writes each stage to the on-screen label and to the
console, and falls through to the lobby after ten seconds even if the art
preload never calls back.
**Why.** The first build ran but showed nothing but a green screen, because
every node authored through the editor tools had been left on the DEFAULT layer
while the camera only renders UI_2D (see CODE_MODE_SETUP.md). A blank screen
carries no information about which step failed, and a loader that can hang
forever on an asset the lobby does not even need is a dead end for no reason.
## 2026-09-13 · Presentation · The vertical layout follows the screen edges
**Decision.** `Layout` holds fixed anchors only for things that live near the
centre, and every one of those is inside ±700. The player's hand and the action
bar are computed from the actual canvas height at render time (`bottomAnchors`),
and the Leave button is pinned to the top-left with a Widget.
**Why.** `fitWidth` means the width is always 1080 units but the height is not:
a 4:3 phone sees 1440 units (±720) and a 21:9 phone sees 2520 (±1260). The first
version used fixed design coordinates, which put the Leave button at y = 840 and
the bottom of the hand at −860 — both off screen on a 4:3 phone, the very case
docs/UI.md asks for. The table is also a single row of up to six slots rather
than two rows of three, which buys back the ~215 units of height that 4:3 cannot
spare, and the row is centred on the cards actually in play so a lone attacking
card sits in the middle of the table.
## 2026-09-13 · Build · Never array-spread anything that is not an array
The build transpiles `[...a, ...b]` to `[].concat(a, b)`. That is right for
arrays, but `Array.prototype.concat` does not expand an arbitrary iterable — it
appends it as a single element. `[...this._views.values()]` therefore produced a
one-element array holding the Map iterator, and the first card ever recycled
threw `view.cancelAnimations is not a function`.
**Decision.** Use `Array.from(...)` for anything that is not already an array,
and `npm run check:spreads` fails the build on `...x.values()`, `...x.keys()`,
`...x.entries()` and spreads of things named like a Map or Set.
**Why.** It type-checks, it reads correctly, and it only fails at runtime on a
real build — exactly the kind of mistake a static check earns its keep on.