diff --git a/docs/functional-spec-v2.md b/docs/functional-spec-v2.md index 186bc7b..95682e2 100644 --- a/docs/functional-spec-v2.md +++ b/docs/functional-spec-v2.md @@ -1,4 +1,4 @@ -# Christian Habit Tracker TCG - Functional Spec v3 +# Christian Habit Tracker TCG - Functional Spec v4 I think a good name for the application would still be **Sanctification TCG**; as the objective is to grow in Christ and move towards holiness. @@ -60,25 +60,27 @@ Habit tracking is private by default. This becomes especially important with negative habits because the user may be putting extremely sensitive information into the application. -I want sensitive customer content to be encrypted in a way where the server cannot casually read it. Statistics can still exist where possible, but content such as a user's sins, struggles, custom habit names, notes, etc. should not just be plaintext sitting in the database for a server administrator to inspect. +I want sensitive user-authored content encrypted **on the client before it is stored or synchronized**. The backend should only receive the minimum unencrypted metadata it actually needs to run schedules, rewards, accounts, and explicitly initiated social features. -Exactly how we accomplish this is more of a technical-spec question. Client-side encryption and potentially some kind of BYOK design are worth exploring. The important functional requirement is that private content should be private even from us as much as reasonably possible. +That means the server may need to know that an opaque habit ID was scheduled and completed, but it should not casually be able to read the user's custom habit name, notes, sins, struggles, negative-habit details, or detailed accountability content. -Accountability sharing should also be obscure by default. A user should be able to say something like: +Negative-habit content and history should get the strongest protection because they do not participate in the card economy and may contain especially sensitive information. + +The exact cryptographic design, key wrapping, multi-device synchronization, and recovery system belong in the technical/security spec. I no longer think BYOK needs to be part of the design. Whatever recovery model we use also cannot quietly undermine the privacy promise by leaving us with a master key that can read everything. + +Accountability sharing should still be obscure by default. A user should be able to say something like: > Daniel needs your prayer today. without automatically telling the accountability partner exactly what happened. -The user can explicitly choose to share more detail if they want to. - ---- +The exact detailed-sharing permission model can be worked out later. ## Christianity -At the core foundation, what we consider Christianity for the purposes of this application is currently founded in the words and logic of the Nicene Creed. +At the core foundation, what we consider orthodox Christianity for the purposes of this application is founded in the words and logic of the Nicene Creed. -This is the standard **for now** and can be revisited later if needed. +This is the doctrinal baseline for the application. Beliefs and movements outside the core theology expressed by the Nicene Creed are outside that baseline and may be identified as heretical or non-Nicene where that is historically and theologically appropriate. > We believe in one God the Father Almighty, Maker of heaven and earth, and of all things visible and invisible. @@ -97,62 +99,44 @@ Rather than treating Catholic, Orthodox, Protestant, etc. as completely separate The shared core can contain things such as: * Scripture - * Biblical people - * Biblical places - * Biblical events - * Early church history - * Major ecumenical councils - * Core Christian doctrines - * Things generally shared within Nicene Christianity -Tradition-specific collections can then include: - -* Catholic - -* Orthodox - -* Protestant - -* Potentially more specific traditions later - A user does **not** pick one tradition and get locked into it. A Catholic-specific card can be visible to and collected by an Orthodox or Protestant user, and vice versa. I want people to be able to collect every tradition because part of the point is also learning what other Christians actually believe and where traditions differ. +The detailed tradition taxonomy now lives in the **Master Card Catalog** rather than being duplicated here. That should be the canonical place for deciding how deep individual tradition collections go. + Maybe the user can choose a UI/theme that matches their tradition at some point, but that would be visual and not limit the cards they can collect. ### Controversies / Non-Nicene Movements -I still want historical heresies and theological controversies represented because they are genuinely interesting and useful to learn about. I just don't think a generic "Heretic Deck" is the best structure. +I still want historical heresies and theological controversies represented because they are genuinely interesting and useful to learn about. I just do not want every disagreement thrown into one generic "Heretic Deck." -This can include things such as: +The Nicene Creed is the line for the application, but there are different kinds of disagreement around that line: -* Arianism +* **Intra-Nicene controversy** - disputes between traditions that remain inside the doctrinal baseline. +* **Historical heresy** - teachings historically condemned as contrary to core Christian doctrine. +* **Schism / ecclesial controversy** - disputes primarily about communion, authority, jurisdiction, or church structure rather than rejection of Nicene doctrine. +* **Non-Nicene movement** - later movements or religious bodies whose theology falls outside the Nicene baseline. -* Gnosticism +This can include things such as Arianism, Gnosticism, Pelagianism, Nestorian controversies, Latter-day Saint theology, Jehovah's Witnesses, and other historically disputed or non-Nicene movements. -* Pelagianism +The cards should be clear about what the person, movement, or teaching actually believed, its historical context, why the issue became controversial, whether it falls inside or outside the Nicene baseline, and how the Nicene position differs when it falls outside it. -* Nestorian controversies +The application does not need to pretend every theological position is equally compatible with Christianity in order to describe those positions fairly. -* Mormonism / Latter-day Saint theology +At the same time, these are still educational cards. They are not insult cards. A label like "heresy" should not replace an actual explanation of what was taught and why it matters. -* Jehovah's Witnesses +Disagreements within Nicene Christianity should also be represented as genuine disagreements without implying that one side automatically falls outside Christianity unless the underlying issue actually crosses the Nicene boundary. -* Other non-Nicene or historically disputed movements - -The card should explain what the person/movement taught, why it became controversial, and how it differs from the Nicene standard we are using for the application. - -The card cannot contain all the information so we can also make sure to have a panel for more information or resource links to learn more about it. It can help explain the position, why it matters historically/theologically, and how it compares to the Nicene standard. They aren't insult cards. - -This should be educational rather than just putting a big "HERETIC" stamp on people and calling it a day. +The goal is doctrinal clarity, historical accuracy, and charitable presentation. ### The Trinity Card @@ -160,55 +144,31 @@ I no longer think The Father, The Son, and The Holy Spirit should be the highest I do still really like the idea of the Trinity having a unique place in the application though. -One idea is that during the tutorial - or immediately after skipping the tutorial - every user is given **The Trinity** as their first and permanent card. +The working mechanical idea is that during the tutorial - or immediately after skipping it - every user is given **The Trinity** as their first and permanent foundation card. -This card would be different from every other card in the system: +This card is different from every other card in the system: * It is given, not earned. - * It cannot be traded. - -* It cannot be destroyed. - -* It is always considered mint condition. - -* The user can customize the finish/material presentation however they like without affecting rarity or the economy. - +* It cannot be burned or consumed in a trade-up. * It does not participate in normal rarity or population mechanics. -The symbolism I like here is that the most important thing in the collection is something you did not grind for or earn. It was given to you and you cannot lose it. - -The exact theological messaging obviously needs some thought because traditions differ on some of the implications there, but I think the overall meaning is good. - -**Initial verse thought:** John 3:16. It seems like a pretty decent fit for the idea of something given rather than earned. - -The artwork for this card also needs special consideration. I do not want us to accidentally make some weird literal depiction of the Father / Son / Holy Spirit just because we need card art. - ---- +The exact name, theological framing, Scripture, artwork, and customization rules still need their own content/art pass. I do not want to force those decisions into the functional spec before they have had enough thought. ## Core Application Loop The rough loop is: 1. Open the application. - 2. See today's habits and current pack state. - 3. Complete and report habits honestly. - -4. Improve the day's reward / pack. - -5. Open it now or save it for later. - +4. Add cards to the day's pack as reward-eligible habits are completed. +5. Open the pack now or leave it sealed for later. 6. Collect cards and variants. - 7. Inspect, organize, learn from, and eventually trade cards. - 8. Come back tomorrow. -The exact pack reward model still needs to be worked through. - ---- +The important part is that completing habits adds **more card draws**, not better hidden rarity odds. ## Habit Tracking @@ -216,23 +176,49 @@ Ultimately this is still a habit tracker at its base. ### Positive Habit Tracking -I think initially we will start with a couple of base good habits to build on: +I want users to be able to track predefined or custom positive habits. + +Some obvious starting examples are: * Prayer - * Bible reading - * Weekly church attendance - * Study - something like a devotional, a chapter of Christian literature, digging into theology, etc. +* Volunteering -* Volunteering ?\* - I am still not sure how to track this cadence. It doesn't feel like a daily thing but it is also obviously a category that builds Christian values. +Habits can have different cadences. For M1 I care about daily, weekly, and monthly tracking. -Custom habits can come later or be included early depending on how much work it ends up being. +#### Daily habits -You can log up to the day prior and still receive whatever reward that day qualified for. +A user can track as many daily habits as they want, but only **five** can be designated as reward-eligible at a time. -You can always log older days/weeks of progress toward habits for your own records and consistency statistics, but they should not generate retroactive packs indefinitely. +Each completed reward-eligible daily habit adds two cards to that day's pack. + +Changing which daily habits are reward-eligible takes effect the following day. This is not meant as some giant anti-cheat system; it just keeps the meaning of the day's commitment stable. + +#### Weekly habits + +Weekly habits are tracked separately from daily habits. + +A user can track as many weekly habits as they want, but only **three** can be reward-eligible at a time. + +A weekly habit may require one or more completions during the week. It still occupies one weekly reward slot and awards its two cards only once, when its weekly target has been met. + +That gives things such as church attendance, Bible study, or volunteering a real place in the reward system without forcing them into an artificial daily cadence. + +#### Monthly habits + +Monthly habits are supported as a tracking cadence, but they do **not** generate cards in M1. + +They can still contribute to history and consistency statistics. If monthly rewards ever become useful, that can be revisited later. + +#### Retroactive logging + +Reward-eligible habit completion can be logged through the end of the following calendar day. + +During that grace period, changes can still add or remove cards from an **unopened** daily pack. + +Once the pack has been opened, its reward state is final. Older habits can still be edited for history and consistency purposes, but they cannot retroactively change card rewards. ### Consistency Instead of Streaks @@ -242,23 +228,22 @@ A streak makes one missed day disproportionately destructive. Going from 93 days Consistency is a much stronger metric. -We can track things such as: +For M1 I want to track: * 7-day consistency - * 30-day consistency - * 90-day consistency - * Lifetime completion rate - * Total completions - * Returning after time away -The exact windows and which ones affect rewards are still subject to change. +Consistency is calculated against **scheduled opportunities**, not raw calendar days. If a habit is only scheduled three times per week, the other four days do not count against it. -This also gives us more interesting achievement opportunities without making the user feel like one missed Tuesday erased three months of work. +Consistency is informational and does **not** modify card rewards. + +That is intentional. The person already receives the reward when they complete the habit. I do not want someone who had a rough month to come back and discover that they are also economically disadvantaged because their consistency score fell. + +We can still recognize consistency milestones or returning after time away with messaging, visual acknowledgement, achievements, binder/profile decoration, etc. Those should remain non-economic. ### Negative Habit Tracking @@ -286,6 +271,12 @@ If someone falls after 100 days and reports it honestly, we should not turn that The abstinence count resets because that is simply factual, but the user's larger journey does not. +Negative-habit tracking is **economically separate from the TCG**. Abstinence, honest reporting, recovery milestones, and returning after failure do not generate cards or improve pack odds. + +That avoids creating a system where failure or recovery becomes part of an optimal card strategy, and it avoids making a lapse feel like an additional economic punishment. + +A positive replacement behavior can still be tracked separately as an ordinary reward-eligible positive habit if the user wants. + ### Recovery Milestones After a fall, I like the idea of recovery milestones: @@ -314,23 +305,15 @@ The exact wording will need theological/content review later, but that is the to The application should never make the user feel like they should avoid opening it because they are ashamed of what happened. -### Voluntary Card Burning +### Card Burning / Trade-Up Presentation -I still like the visual and ceremonial idea of burning a card. I just don't want it attached to mandatory punishment for honestly reporting a failure. +I still like the visual idea of cards burning, but for M1 I think its best use is as the **trade-up animation** rather than a separate punishment or economy. -Maybe card burning becomes voluntary. +When ten cards are consumed in a trade-up, they can burn together and form the new higher-rarity card. -Possible uses: +Any standalone voluntary burning / personal ceremony idea can be deferred. -* A user voluntarily burns a card as part of some personal ceremony / reset. - -* Burning duplicates becomes part of the card economy. - -* Cards can be consumed in future crafting/trade-up systems. - -The exact implementation is still open. - ---- +Most importantly, card destruction should never be mandatory punishment for honestly reporting a failure. ## TCG / Collection @@ -340,13 +323,31 @@ There is no actual card combat system planned. The fun is collecting, opening, i ### Collection Completion -I still like the general idea that collecting one of every base card by yourself should take something like at least a year of very consistent usage. +Collection completion is based on **base card identity**. -That number is very subject to change once we actually model the economy and start testing it. +If you own one instance of a card, that identity counts as collected regardless of its finish, material, printing, artwork variant, weight, or other instance-level properties. -If someone completes the full base compendium, regardless of finish/material/etc., I think some kind of prestige system could be cool. +The initial full catalog is currently **500 base card identities**. -Maybe it decorates the profile or binder rather than just resetting everything. +I no longer think the useful target is "you should hit exactly 500/500 after one year." Random duplicates naturally make the last few cards much harder than the first few hundred. + +Using the initial rarity model, the target experience for a maximally consistent user opening normal rewards is roughly: + +* 50% completion after about 4 weeks. +* 75% completion after about 9-10 weeks. +* 90% completion after about 19 weeks. +* 95% completion after about 28 weeks. +* Around 99% completion after about one year. + +Those are economy-model targets, not promises to an individual user. + +Normal packs do not use duplicate protection or quietly favor cards missing from the user's collection. + +The final few percent should be a substantially longer collector challenge if someone relies only on random packs. Trading and trade-ups can eventually give people more directed ways to close that gap. + +Variant collecting is intentionally much deeper than base-compendium completion. Pulling one Moses does not mean there is nothing left to chase for Moses. + +If someone eventually completes the full base compendium, I still think some kind of prestige/profile/binder recognition could be cool rather than resetting anything. ### Binders @@ -372,75 +373,106 @@ I previously mentioned microtransactions here, but there is currently no monetiz ### Packs -I still need to deliberate on exactly how packs are awarded, how many cards each pack contains, and all of the statistics behind them. +The daily reward is intentionally simple: completing habits gives the user **more draws**, not secretly better draws. -#### Working Daily-Pack Idea +#### Daily Pack -I still like having some kind of login pack because it establishes the initial hook. +Every day begins with a **2-card login pack**. -The user opens the app and sees the pack, which can immediately remind them: +Up to five daily habits can be reward-eligible. Each completed reward-eligible daily habit adds **2 cards**. -> Oh right. I need to actually accomplish my tasks today. +That means: -One idea I like more than simply giving one pack per task is that the daily pack **upgrades as tasks are completed**. +```text +Login only 2 cards +1 / 5 completed 4 cards +2 / 5 completed 6 cards +3 / 5 completed 8 cards +4 / 5 completed 10 cards +5 / 5 completed 12 cards +``` -For example: +Twelve cards is the maximum normal daily pack for M1. -* Login / start of day - very weak pack or only a couple cards. +The user can open the pack before finishing the day's habits if they want. Opening it permanently finalizes that day's reward state. -* Complete one task - improve the pack. +If reward potential remains, the application should warn them that opening is irreversible and that later habit completions will still count toward history/consistency but will no longer add cards to that pack. The user can disable this warning in settings. -* Complete additional tasks - improve odds, card count, or pack quality. +The same warning applies when opening an unfinished previous-day pack during its grace period. -* Complete everything for the day - reach that day's maximum pack state. +#### Weekly Pack -This keeps the login hook without making simply opening the application equivalent to actually doing the habits. +Weekly reward-eligible habits build a separate weekly pack. -I am not sure yet whether task completion should: +There is no weekly login reward. -* Improve rarity odds. +Up to three weekly habits can be reward-eligible and each completed weekly target adds **2 cards**, for a maximum weekly pack of **6 cards**. -* Increase the number of cards in the pack. +A weekly habit can require multiple completions, but it only awards its two cards once when the weekly target is satisfied. -* Improve expected wear / quality. +Monthly habits do not generate a pack in M1. -* Upgrade the pack type itself. +At maximum reward generation this gives us a clean upper bound of **90 cards per week**: -* Use some combination of the above. +```text +12 daily cards × 7 days = 84 +Maximum weekly pack = 6 + ---- +Maximum = 90 +``` -I particularly like the idea that the initial login pack might only have something like two cards and completing tasks adds additional cards to it. That may feel less casino-like than constantly manipulating rarity odds, but we need to test it. +#### Pack Finalization and Saving -#### Saving Packs +Users can save earned packs and open them later. -Users can choose to save earned packs and open them later. +A pack's contents are generated when the pack is **finalized**, not when it is opened. -Some people may want to open every day. Other people may want to save ten or twenty packs and have one larger opening session. +A daily pack finalizes when: -Once a day's pack is finalized, saving it should not continue to improve it later unless we intentionally design a mechanic around that. +* The user opens it early. +* It reaches its maximum reward state. +* Its reward-eligible grace period expires. + +When a pack finalizes, the system generates and stores the actual card instances inside it - identity, rarity, finish, material, printing, weight, and whatever other instance properties are active in the economy. + +Opening later is only the reveal. It does not reroll anything. + +That means a saved pack can sit unopened indefinitely without being affected by future economy changes, and a client crash during the reveal cannot change what was awarded. + +It is useful to distinguish the time a card instance was created/finalized from the time it was actually revealed to the user. #### Pack Odds -Pack probabilities should initially be globally fixed. +Normal daily and weekly card slots use the same global rarity distribution. -We should still wire the probability variables in a way where they can be adjusted later if testing shows that the economy is developing too quickly or too slowly. +Each slot is an independent draw: -I do **not** want probabilities dynamically changing per user behind the scenes just to manipulate engagement. +| Rarity | Pull Probability | +| --- | ---: | +| Common | 59.00% | +| Uncommon | 28.50% | +| Rare | 9.00% | +| Extraordinary | 2.75% | +| Legendary | 0.75% | +| **Total** | **100.00%** | + +Habit completion does **not** change these odds. + +There are no guaranteed Rare+, Extraordinary+, or Legendary slots in normal packs. + +Within a rarity tier, eligible card identities initially have equal probability unless we intentionally introduce a different set-specific mechanic later. + +These are M1 starting values, not sacred constants. They should be configurable and validated through simulation and playtesting. + +If we rebalance them later, the change should be global and transparent. I do not want probabilities dynamically changing per user behind the scenes to manipulate engagement. #### Pack Types -I still think different types of packs could be fun, with different expected card qualities or presentation. +I still think special pack types could be fun later, potentially with different presentation or explicitly different rules. -Initial placeholder ideas: +Initial names like Common / Gold / Illuminescent were only placeholders. -* Common / normal - -* Gold - -* Illuminescent - -Names and exact meaning are still TBD. - -Different packs can have different opening animations. +For M1, the normal daily and weekly reward packs are the important part. Special pack types can be worked out later. ### Pack Opening Experience @@ -468,111 +500,56 @@ Blender is not required even if we pursue convincing wrapper tearing or crumplin ### Rarity -I still want different tiers, although I want to find better Biblical/thematic names for them eventually. - -Placeholder tiers: +The rarity structure for M1 is: * Common - * Uncommon - * Rare - * Extraordinary - * Legendary -Rarity should represent **prominence within the collection**, not spiritual worth, holiness, or importance to God. +Rarity represents **prominence within the collection and pull scarcity**, not spiritual worth, holiness, or importance to God. -I also really like the idea that different sets can have independent rarity structures. +Each base card identity has one canonical rarity. -For example, rarity within a Biblical People set does not necessarily need to mean the exact same thing as rarity within a Church History set or a Catholic collection. +A Legendary card participates in the same Legendary economy whether it represents a Biblical person, historical event, doctrine, tradition-specific subject, or something else. Sets do not define independent rarity probabilities in M1. -This needs more work when we start building the actual card catalog. +Finish, material, printing, artwork variant, and weight are separate collectible properties. A holographic Common is still Common, and a plain-paper Legendary should still visually read as Legendary. + +The current 500-card master catalog is distributed like this: + +| Rarity | Card Identities | Share of Catalog | +| --- | ---: | ---: | +| Common | 192 | 38.4% | +| Uncommon | 150 | 30.0% | +| Rare | 101 | 20.2% | +| Extraordinary | 39 | 7.8% | +| Legendary | 18 | 3.6% | +| **Total** | **500** | **100.0%** | + +The percentage of identities in a rarity tier is intentionally different from that tier's pull probability. + +Rarity should also materially affect base composition/framing/presentation rather than just adding more glow. The exact visual rules belong in the Art Direction document. ### Filler Cards -I also like the idea of having a very small pool of intentionally simple filler cards. +I am deferring a dedicated filler-card economy for M1. -Something like **4-6 filler cards** can share the same draw pool as Common cards. These are not meant to be bad cards or throwaway garbage, but they should be the simplest things in the collection and something the user expects to see fairly often. +With a 500-card catalog already in place, I do not think we need a special filler sub-pool just to make duplicates normal or to pad the Common tier. -The point is partly practical: - -* They give the bottom of the pack pool some breathing room without requiring dozens of unique Common cards immediately. - -* They make repeated pulls and duplicates a normal part of opening packs. - -* They give us natural low-value inputs for future burning / crafting / trade-up systems. - -* They help make normal Common cards feel a little more meaningful when one appears. - -* They give us an even simpler art tier to work with when testing the renderer. - -Visually, filler cards should be simpler than normal Common cards. - -I am imagining things such as: - -* Very simple borders. - -* No gold. - -* Very limited colors. - -* Simple objects, environments, symbols, or background characters rather than large heroic portraits. - -* Minimal ornament. - -* Minimal text. - -* Wide/simple compositions rather than tightly framed character portraits. - -They should still look intentional and belong to the same product. I do **not** want them to feel like ugly junk cards that exist only to disappoint the user. - -I also do not think filler needs to become a new rarity above or below Common. It can just be a special classification that participates in the Common pull pool. - -For example: - -```text -Common Pull Pool - - Filler - Simple Card A - Simple Card B - Simple Card C - Simple Card D - - Normal Common - Timothy - Ruth - Jonah - Stephen - etc. -``` - -The exact weighting between filler and normal Common cards is still open. - -We also need to decide whether filler cards count toward the normal base compendium / prestige completion or live in a small separate filler collection. +Simple subjects can still exist as perfectly normal Common cards. If economy testing later shows a real need for a high-frequency low-value classification, we can bring the filler concept back then. ### What Makes a Card Unique At a high level, I think a card instance is made up of things such as: * Card Identity - -* Artwork - +* Artwork / artwork variant * Rarity - * Finish - * Printing - * Material - -* Wear - -* Potential imperfections - +* Simulated weight * Provenance / who originally opened it and when There should still be a clean distinction between the base **card definition** and an individual **card instance**. @@ -580,237 +557,232 @@ There should still be a clean distinction between the base **card definition** a For example: ```text - Card Definition - -    David - Card 001 - -    Artwork A - -    Legendary - -    Biblical People Set + David - Card 001 + Artwork A + Legendary + Biblical People Card Instance - -    David - Card 001 - -    Holographic - -    Borderless - -    Metal - -    97.3% condition - -    Opened by Daniel - -    Opened on - + David - Card 001 + Holographic + Borderless + Metal + 128.4g + Opened by Daniel + Opened on ``` -That general contract should be renderer-independent. The database should not care whether Three.js, Flutter Scene, or some future engine is displaying the card. +Randomized wear/condition and random imperfections are **not** part of the card-instance model. + +That general contract should stay renderer-independent. The database should not care whether Three.js or some future engine is displaying the card. ### Finish -Possible finishes: +Finish is an independent collectible property and needs to be visually obvious when interacting with the card. -* Matte +Things such as matte, glossy, textured, holographic, foil, weathered, antiqued, distressed, patinated, or future variations are all art-direction territory. -* Glossy +I do not want the functional spec to prematurely lock the finish taxonomy. The exact finish types, composability, compatibility rules, subtypes, and visual implementation belong in [Art Direction](./art-direction.md). -* Textured - -* Holographic - -* Foil - -* Potentially multiple kinds of holographic/foil patterns later - -Different finishes should be visually obvious when interacting with the card. A holographic card should not just have a little "Holographic" tag underneath it. +What matters functionally is that finish participates in card generation, population grouping, and trade-up inheritance and remains separate from base rarity. ### Printing -Placeholder printing types: +Printing is an independent collectible property that can materially alter the artwork composition, framing, layout, or text treatment rather than just being metadata. -* Normal +Exact printing types - including what ideas such as Normal, Borderless, Textless, or Boundless actually mean - belong in [Art Direction](./art-direction.md). -* Borderless - -* Textless - -* Boundless - -These can materially alter the card artwork/layout rather than just being metadata. +Functionally, printing participates in card generation, population grouping, and trade-up inheritance. ### Material -Possible materials: +Each card instance has exactly one material representing its physical substrate. + +M1 candidates are: * Paper - -* Plastic - -* Wood - * Linen - +* Wood * Metal -Material should affect the way the card looks and reacts to light. +Paper is the default. + +Material is independent of base rarity and participates in card generation, population grouping, trade-up inheritance, and simulated weight. + +The exact visual treatment, physical appearance, compatibility constraints, thickness, lighting response, and renderer behavior belong in [Art Direction](./art-direction.md). ### Wear / Condition -I still like every normal card instance having a wear/condition value between `0.00` and `1.00`, but I am reconsidering what the low end actually looks like. +I am cutting randomized wear / condition from the card-instance model entirely. -`1.00` would be essentially pristine. +The more I thought about it, the more it felt like negative variance rather than a fun collector property. Getting the Legendary you wanted in the finish, material, and printing you wanted only for it to be a "bad" damaged copy would feel like being punished for no reason - especially when packs are earned through habits rather than something you can endlessly buy or grind that same day. -`0.00` does **not** need to mean the card looks like it went through a washing machine. It could still be clearly readable and attractive, just visibly scratched / worn / damaged compared to a mint card. +Finish, material, printing, artwork variants, and rarity already give us plenty of collector depth. -We can expose condition to users as percentages and named bands. - -Very rough example: - -* Mint: `1.00 - 0.95` - -* Minimal Wear: `0.95 - 0.75` - -* Additional bands: TBD - -The actual ranges need to be modeled and tested rather than guessed here. - -The wear value should affect visible rendering where practical: - -* Edge whitening - -* Surface scratches - -* Small scuffs - -* Roughness changes - -* Other subtle damage - -Even poor-condition cards should still look like something someone wants to collect. +We can still intentionally design things such as weathered, antiqued, distressed-parchment, or patinated treatments if they look good. Those should be deliberate visual variants, not a quality score that makes one copy objectively worse. ### Weight I still like the completely unnecessary physical-card idea that cards have simulated weight. -Cards might have a baseline around 100g with slight variance, while material/finish can add or subtract minor amounts. +Each card instance can have a simulated physical weight derived primarily from its material, with a small deterministic variation and optional minor adjustments from finish/printing. -Eventually there could be an achievement or unlockable digital scale that lets the user weigh an unopened pack and try to infer what might be inside. +Weight does not affect base rarity, compendium completion, or trade-up eligibility. -This is very much a future fun feature and not core functionality. +Eventually there could still be some fun digital-scale idea for unopened packs, but the advanced gameplay around weight is deferred. The metadata itself is cheap enough to keep now. ### Imperfections -This is still a maybe. +Cut. -We could generate some number of masks for things like: +I do not want randomized fingerprints, hairs, print defects, or other accidental-looking imperfections as an instance-level collectible system. -* Fingerprints - -* Hair - -* Minor print artifacts - -* Surface marks - -Then an individual card instance could have a very small chance of receiving one based on a deterministic seed. - -This would make otherwise-identical variants slightly more interesting. +If we want intentional visual irregularity, it should come from a designed finish, material, printing, or artwork treatment rather than a random defect roll. ### Population / Provenance -Cards should retain metadata about when they were opened and who originally opened them. +Cards should retain meaningful provenance such as who originally opened them and when. -I also like tracking population for meaningful combinations such as: +For population, I want to track meaningful collectible combinations: ```text - -Card ID + Finish + Printing + Material - +Card Identity ++ Artwork Variant ++ Finish ++ Material ++ Printing ``` -That way someone can see that their metallic holographic borderless Adam is 1 of 1, while another combination may already have 1,000 copies. +The system should track both: -I don't think wear, weight, and individual imperfections should count toward this population grouping because then effectively everything becomes a fake 1-of-1. +* Total instances of that combination ever created. +* Number of those instances currently surviving. + +Burning / trade-up consumption reduces the surviving population but does not reduce the historical "ever created" count. + +Ownership, opener identity, timestamps, trade history, and simulated weight do not create separate populations. + +I do **not** want to keep an enormous database of fully queryable dead card instances forever just for provenance. Aggregate historical/destruction counters are enough for destroyed cards in M1. + +For living cards, original opener and opening date remain part of provenance even after trading. + +Whether population statistics are globally public everywhere can be decided later. ### Card Economy / Destruction -This is one area I want to work on more specifically. +M1 will use a straightforward **10-to-1 trade-up system**. -One mechanic that might be useful is some kind of trade-up / crafting system similar in spirit to Counter-Strike trade-ups. +A trade-up consumes ten card instances of the same rarity and creates one card from the immediately higher rarity: + +```text +10 Common -> 1 Uncommon +10 Uncommon -> 1 Rare +10 Rare -> 1 Extraordinary +10 Extraordinary -> 1 Legendary +``` + +The rarity upgrade is guaranteed. Trade-ups cannot skip tiers. + +For M1, the resulting card identity is selected randomly from the eligible identities in the next rarity tier. The identities of the ten inputs do not influence the resulting identity. + +Set/category targeting can come later. + +#### Instance-property inheritance + +Finish, material, and printing from the ten input cards directly influence the corresponding property on the output. + +Each input contributes an equal 10% share to that property's roll. For example: -> Consume 20 Common cards to generate 1 Uncommon card. +```text +Finish -The exact number and output obviously need to be modeled. +10 Holographic +-> 100% Holographic output -I like this because it gives duplicates a use while also permanently removing cards from circulation. +7 Holographic +3 Matte +-> 70% Holographic +-> 30% Matte +``` -Questions we need to answer later: +The same idea applies independently to material and printing: -* Is the output guaranteed to be the next rarity? +```text +Material -* Does finish/material influence the result? +5 Metal +3 Paper +2 Linen -* Can users choose which set the resulting card comes from? +-> 50% Metal +-> 30% Paper +-> 20% Linen +``` -* Are some cards protected from trade-ups? +```text +Printing -* Does a burned/traded-up card remain in provenance history as destroyed? +8 Normal +2 Borderless -I think actual destruction can become an important sink in the economy, but it should mostly be voluntary rather than punishment. +-> 80% Normal +-> 20% Borderless +``` + +Each property is rolled independently. That means five Holographic/Paper cards plus five Matte/Metal cards could produce a Holographic/Metal result. + +If all ten inputs share a property, that property is guaranteed on the output. + +I like this because low-rarity cards can still have meaningful collector/crafting value. Ten holographic Commons are not just ten generic Commons; they can guarantee a holographic Uncommon. + +The ten input cards are permanently consumed and leave the surviving population. We should retain aggregate destruction counts rather than full dead-card records. + +Visually, I like the idea of the ten cards burning together to form the new card. That gives the old burning concept a clear purpose without tying it to punishment or failure. + +More advanced recipes, targeted identities, targeted sets, and other crafting systems can wait until we have tested this economy. ### Content -I want to eventually have a very large repository of cards. - -Possible categories include: - -* People - -* History - -* Icons - -* Items - -* Places - -* Events - -* Theologians - -* Theology - -* Apologetics / arguments - -* Councils - -* Traditions - -* Controversies - -* Probably many more once we actually start cataloging things +The full card library is now being managed separately in the **Master Card Catalog**. The current baseline is 500 base identities across Scripture, history, theology, traditions, controversies, people, items, places, events, and other categories. Unless it is a specific printing such as textless, every card should have meaningful text associated with it. Priority for card text: 1. Scripture where directly relevant. - 2. Quote where directly relevant. - 3. Historical / theological fact. -There should also be a way to click through and learn more or understand why the text/artwork is associated with that card. - The educational side is important. Pulling something unfamiliar should be an invitation to learn what it is rather than just seeing a rarity number. +#### Sources / citations + +Card content should have source metadata sufficient for someone to understand where factual, historical, scriptural, and doctrinal claims came from. + +The face of the card should stay visually concise. Full citations and supporting resources belong mainly in the expanded information / learning panel. + +Where applicable, I want to prioritize: + +1. Scripture. +2. Primary historical or theological sources - creeds, council texts, letters, sermons, confessions, catechisms, or writings by the person represented. +3. Tradition-specific authoritative sources when explaining a tradition's teaching. +4. Reputable secondary historical or scholarly sources for context, chronology, interpretation, or claims that cannot be established from a primary source alone. + +Research tools, general websites, and tertiary summaries can help during research, but they should not be the sole published authority for an important doctrinal/historical claim when something stronger is available. + +The card face can still show concise references such as a Scripture citation, short quote attribution, council/date, or author/work. + +Direct quotations need clear attribution. Where practical that means author/speaker, source work, relevant section/chapter/paragraph/page, and translation/edition where wording materially depends on it. + +If something is genuinely disputed, the information panel should say so rather than flattening the uncertainty. That includes traditional attribution, scholarly disagreement, uncertain dates, and different interpretations among Nicene traditions. + +Sources belong primarily to the **card definition**, not the individual physical-looking instance. + +Who ultimately performs doctrinal/historical review is still deferred until we get deeper into actual card writing. + ### Art Style I think the current art direction is strong enough that we can treat it as the working direction rather than something completely undecided. @@ -825,36 +797,27 @@ Rarity should materially affect the composition and framing rather than just add For example: -* Filler - the simplest compositions, no gold, minimal ornament, limited colors. - * Common - simple geometric borders, restrained colors, wider / calmer compositions. - * Higher rarities - progressively more storytelling, symbolism, ornament, richer framing, and more elaborate compositions. - * Legendary - highly bespoke art / framing and much tighter, more iconic presentation where appropriate. Finish and material remain separate from rarity. A Common holographic card is still a Common card, and a Legendary matte-paper card should still visually read as Legendary. -The more detailed rules belong in [Art Direction](./art-direction.md). +The detailed rules for finish taxonomy, printing definitions, material appearance, compatibility, and religious-art depiction belong in [Art Direction](./art-direction.md). + +At the functional level I only want a few religious-art guardrails: + +* Treat sacred subjects respectfully and intentionally. +* Avoid irreverent, trivializing, or unnecessarily sensational depictions. +* Allow tradition-specific visual treatments where traditions genuinely differ. +* Use symbolic/non-figurative approaches where literal depiction would be inappropriate, uncertain, or theologically sensitive. +* Historical/traditional iconography can inform the artwork where appropriate. +* Rarity should never imply greater spiritual worth. + +The Trinity/Foundation card remains a special art case that needs its own pass. I still expect to lean heavily on LLM/image-generation tooling and MCP servers for creating and iterating on the artwork since I am a software engineer, not an artist. -Tradition/set flavor can still affect things such as: - -* Colors - -* Borders - -* Typography - -* Card backs - -* Ornamentation - -* Maybe runtime material treatment - ---- - ## Visual Experience The visual experience is not just decoration for this application. Opening and physically inspecting the collectible is part of the reward. @@ -866,18 +829,12 @@ Cards should be represented as tactile physical objects rather than static colle When inspecting a card, the user must be able to: * Rotate the card freely. - * Flip between front and back. - * Zoom in to inspect artwork and physical characteristics. - -* Observe material properties responding dynamically to lighting. - -* Observe wear and imperfections where applicable. - +* Observe material and finish properties responding dynamically to lighting. * Return quickly to the originating collection/binder context. -Card finish and material must have visually distinguishable properties. For example, foil, holographic, paper, linen, and metal cards should respond differently to lighting. +Card finish and material must have visually distinguishable properties. For example, foil, holographic, paper, linen, wood, and metal cards should not all react to lighting in the same way. Lower-power devices must be able to fall back to simplified visual representations without affecting gameplay. @@ -904,53 +861,51 @@ If we add a collectible property, the user should ideally be able to see or inte For example: ```text - -Finish        -> visible - -Material      -> visible - -Wear          -> visible - -Imperfections -> visible - -Printing      -> visible - -Weight        -> indirectly observable - -Provenance    -> inspectable metadata - -Population    -> inspectable metadata - +Finish -> visible +Material -> visible +Printing -> visible +Weight -> indirectly observable +Provenance -> inspectable metadata +Population -> inspectable metadata ``` Otherwise there is not much point in storing increasingly complicated metadata that never changes the actual experience. ---- - ## Social Interaction The other important aspect of card collections is trading and having some kind of community around them. -Users can have friends and accountability partners. Those are intentionally different concepts because someone may want to trade/show collections to a lot of people while keeping accountability very private. +I still want friends and accountability partners to be intentionally different concepts because someone may want to trade/show collections to a lot of people while keeping accountability very private. -### Initial Social Functionality +None of the multiplayer/social system is required for M1. The first build should be able to function fully locally. -Things I think would be great: +### Future Social Functionality + +Things I still think would be great later: * View another user's public compendium / binder. - * Offer trades. - * Add friends. - * Add explicit accountability partners. - * Send predetermined messages of encouragement. - * Send Scripture / verses. - * Send generic prayer requests without revealing why. +If a user makes their collection/binder public, useful visible card metadata can include: + +* Card ownership. +* Finish / material / printing. +* Original opener. +* Opening date. +* Population information. +* Trade history. + +The original opener should continue to persist after a card is traded. I do not currently want provenance anonymization. + +Destroyed cards do not need to remain individually visible in historical collection records. Aggregate population/destruction counts are enough. + +Whether population data is globally public outside the context of someone's collection can be decided later. + ### No General Chat I do not want general chat integration. @@ -973,11 +928,9 @@ rather than: > Daniel failed Habit X at 9:42 PM. -The user can choose to share exact habits/details with a specific accountability partner if they want to. +The exact accountability permission levels and what triggers a prayer/accountability signal are deferred for now. -This avoids both hubris and shame - neither of those is the point of the project. - ---- +If we later allow detailed sharing of encrypted habit content, that needs to preserve the privacy model rather than making the server readable just because another user is involved. ## Monetization @@ -1013,466 +966,186 @@ Examples: Exactly what gets exposed and when is a technical/API-spec question. -This is not a priority for the first MVP. +This is not a priority for M1. --- -## MVP +## M1 / First Build There is not really a formal deliverable or deadline right now. This is a project I want to build because the product is interesting and some of the technical problems are fun. -Since I am a software engineer and not an artist or 3D modeler, I expect to lean on LLMs, MCP servers, image generation, and other tooling to help with those parts. +For the first real build, I want something closer to a **fully working local vertical slice** than a multiplayer production launch. -I think the MVP should intentionally focus on the difficult / interesting pieces rather than spending months filling out content. +The important thing is that the whole core experience works locally end-to-end. -### Initial MVP Scope +### Initial M1 Scope Something like: * Web client first. - * Works on desktop and mobile browsers. - -* Basic account/authentication. - +* Fully functional locally; multiplayer is not required. * Positive habit tracking. - * Negative habit tracking with abstinence + honesty/engagement concepts. - +* Daily, weekly, and monthly habit cadences. * Consistency metrics instead of positive-habit streaks. - -* Daily pack/reward prototype. - +* Daily 2-12 card pack/reward flow. +* Separate 0-6 card weekly reward pack. * Ability to save packs. - -* Around 50 real cards. - -* A small filler pool - probably around 4-6 intentionally simple cards sharing the Common pull pool. Whether these are included in the roughly 50-card count or sit on top of it is still open. - +* Around **50 real cards** drawn from the Master Card Catalog, with a few representatives from each major category. * Multiple rarity levels. - * Multiple finishes/materials/printings. - -* Wear rendering. - +* 10-to-1 trade-ups with instance-property inheritance. * Binder / compendium. - * Full 3D card inspection. - * 3D pack-opening flow. - * Basic population/provenance model. +* Simulated card weight metadata. +* Enough economy simulation to begin validating rarity math. -* Enough economy simulation to begin testing rarity math. +I would rather have **50 cards where all of the difficult rendering/material/variant systems actually work** than hundreds of cards that are basically static images. -I would rather have **50 cards where all of the difficult rendering/material/variant systems actually work** than 500 cards that are basically static images. +The full catalog can still contain 500 identities. M1 only needs a representative subset actually implemented. -The rendering canvas, shaders/materials, and card pipeline are some of the more interesting technical challenges to me. +Randomized wear/condition and randomized imperfections are cut from the design. Filler-card economics, multiplayer trading, and advanced social behavior are not required for M1. -Wiring up a backend to a client, normal CRUD, auth, API calls, etc. are much more familiar problems and can be fleshed out separately in the technical spec. - -### Not Required for the First MVP +### Not Required for M1 Likely later: +* Multiplayer/social backend. * Native Android/iOS applications. - * Full trading economy. - -* Advanced social functionality. - +* Advanced accountability sharing. * API/webhook ecosystem. - -* Huge card catalog. - -* Digital pack scale / weight mechanics. - +* Full 500-card production content rollout. +* Digital pack scale / advanced weight mechanics. * Deep achievement/prestige systems. - * Monetization. +* Dedicated filler-card economy. -### MVP art-direction rules +### M1 Art-Direction Rules -Seen in [Art Direction](./art-direction.md) +See [Art Direction](./art-direction.md). -### Sample MVP Collection +### M1 Collection -#### Filler pool +Use around **50 cards from the Master Card Catalog**, with enough variety across categories and rarities to exercise the real renderer and economy. -I think we should reserve roughly 4-6 very simple cards for the filler pool. +The exact list should live in the catalog rather than being duplicated here so the two documents do not drift. -I do not think we need to lock the exact subjects yet, but these should probably lean toward simple objects / places / ordinary imagery rather than major named people. +A handful of deliberately difficult "hero" cards should still exercise things such as: -Potential directions could be things like: - -* A lamp. - -* A fishing net. - -* A loaf of bread. - -* A shepherd's staff. - -* A fig tree. - -* A simple road / path. - -Those are only examples for now. The important part is that filler cards give us a visually and economically simple baseline beneath normal Common cards. - -#### Permanent starter - -| ID    | Card            | Type       | Notes                                                                                                                                                   | -| ----- | --------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| S-001 | **The Trinity** | Foundation | Permanent, nontradeable, cannot be burned or consumed, always mint. User can choose/customize finish/material/printing. Suggested verse: Matthew 28:19. | - - - -#### Others - -|   # | Card                              | Category / Set              | Working Rarity | Content / Visual Hook                                                                                                  | -| --: | --------------------------------- | --------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------- | -| 001 | **Adam**                          | Scripture · Person          | Rare           | Genesis 2–3. Excellent portrait/environment card; Eden imagery.                                                        | -| 002 | **Eve**                           | Scripture · Person          | Rare           | Genesis 2–3. Companion visually to Adam without making them a paired requirement.                                      | -| 003 | **Noah**                          | Scripture · Person          | Uncommon       | Genesis 6–9. Rain, ark, rainbow imagery.                                                                               | -| 004 | **Abraham**                       | Scripture · Person          | Extraordinary  | Genesis 12–22. Stars/sky imagery could make an excellent foil.                                                         | -| 005 | **Sarah**                         | Scripture · Person          | Uncommon       | Genesis 18, 21.                                                                                                        | -| 006 | **Moses**                         | Scripture · Person          | Legendary      | Burning bush / Sinai gives us several strong artwork directions.                                                       | -| 007 | **Ruth**                          | Scripture · Person          | Common         | Book of Ruth. Wheat-field imagery; intentionally beautiful despite Common rarity.                                      | -| 008 | **David**                         | Scripture · Person          | Legendary      | Shepherd/king imagery. Could support multiple artwork printings eventually.                                            | -| 009 | **Elijah**                        | Scripture · Person          | Rare           | 1 Kings 17–19. Fire-heavy card effects would be useful renderer testing.                                               | -| 010 | **Isaiah**                        | Scripture · Person          | Rare           | Isaiah 6 / prophetic imagery.                                                                                          | -| 011 | **Jonah**                         | Scripture · Person          | Common         | Jonah. Strong visual identity even at Common.                                                                          | -| 012 | **Mary**                          | Scripture · Person          | Extraordinary  | Luke 1–2. Could eventually have tradition-specific artwork variants.                                                   | -| 013 | **Joseph of Nazareth**            | Scripture · Person          | Uncommon       | Matthew 1–2.                                                                                                           | -| 014 | **John the Baptist**              | Scripture · Person          | Rare           | Matthew 3 / John 1. Water/wilderness imagery.                                                                          | -| 015 | **Peter**                         | Scripture · Person          | Legendary      | Gospels / Acts. Keys could appear in some tradition-specific printings without defining the base card around them.     | -| 016 | **Paul**                          | Scripture · Person          | Legendary      | Acts 9 + epistles.                                                                                                     | -| 017 | **Mary Magdalene**                | Scripture · Person          | Rare           | John 20. Resurrection witness gives the card a clear textual focus.                                                    | -| 018 | **Stephen**                       | Scripture · Person          | Common         | Acts 6–7.                                                                                                              | -| 019 | **Timothy**                       | Scripture · Person          | Common         | Acts 16 / Timothy.                                                                                                     | -| 020 | **Gabriel**                       | Scripture · Angel           | Rare           | Luke 1. Visually lets us test a non-human person card.                                                                 | -| 021 | **Creation**                      | Scripture · Event           | Extraordinary  | Genesis 1. Very different composition from portrait cards.                                                             | -| 022 | **The Flood**                     | Scripture · Event           | Uncommon       | Genesis 6–9. Water/rain shader experimentation.                                                                        | -| 023 | **The Exodus**                    | Scripture · Event           | Extraordinary  | Exodus 12–14. Sea/fire/cloud imagery.                                                                                  | -| 024 | **The Ten Commandments**          | Scripture · Event           | Rare           | Exodus 20. Stone/tablet materials make this particularly useful visually.                                              | -| 025 | **The Nativity**                  | Scripture · Event           | Extraordinary  | Luke 2.                                                                                                                | -| 026 | **The Baptism of Jesus**          | Scripture · Event           | Rare           | Matthew 3.                                                                                                             | -| 027 | **The Last Supper**               | Scripture · Event           | Rare           | Luke 22 / 1 Corinthians 11.                                                                                            | -| 028 | **The Crucifixion**               | Scripture · Event           | Legendary      | Central subject, but rarity represents prominence in this collection rather than "holiness."                           | -| 029 | **The Resurrection**              | Scripture · Event           | Legendary      | Excellent candidate for one of the MVP's most visually elaborate cards.                                                | -| 030 | **Pentecost**                     | Scripture · Event           | Extraordinary  | Acts 2. Fire/light effects.                                                                                            | -| 031 | **The Ark of the Covenant**       | Scripture · Item            | Rare           | Exodus 25. Excellent first metal/gold-material showcase.                                                               | -| 032 | **Bethlehem**                     | Scripture · Place           | Common         | Micah 5:2 / Luke 2.                                                                                                    | -| 033 | **Jerusalem**                     | Scripture · Place           | Rare           | Lets us establish how location cards differ visually from person/event cards.                                          | -| 034 | **The Sea of Galilee**            | Scripture · Place           | Common         | Strong environmental artwork; water reflection testing.                                                                | -| 035 | **The Empty Tomb**                | Scripture · Place           | Extraordinary  | Resurrection-associated without duplicating the Resurrection event card.                                               | -| 036 | **The Nicene Creed**              | Core · Doctrine/History     | Legendary      | Basically the thesis statement for the app's doctrinal baseline.                                                       | -| 037 | **The Council of Nicaea**         | Church History · Event      | Extraordinary  | AD 325. Also introduces historical-event cards outside Scripture.                                                      | -| 038 | **Athanasius of Alexandria**      | Church History · Theologian | Rare           | Natural connection to Nicaea and the Incarnation.                                                                      | -| 039 | **The Incarnation**               | Core · Theology             | Extraordinary  | John 1:14 provides a strong scriptural anchor.                                                                         | -| 040 | **The Resurrection of the Dead**  | Core · Theology             | Uncommon       | 1 Corinthians 15 / Nicene Creed.                                                                                       | -| 041 | **The Great Commission**          | Core · Teaching             | Uncommon       | Matthew 28:18–20.                                                                                                      | -| 042 | **The Lord's Prayer**             | Core · Teaching             | Common         | Matthew 6:9–13. Good example of a Common card that everyone should still want.                                         | -| 043 | **The Sermon on the Mount**       | Core · Teaching             | Uncommon       | Matthew 5–7.                                                                                                           | -| 044 | **The Rosary**                    | Catholic · Item/Practice    | Uncommon       | Tests explicitly tradition-specific educational material.                                                              | -| 045 | **Christ Pantocrator**            | Orthodox · Icon             | Rare           | Gives the MVP an actual icon card and a very different visual format.                                                  | -| 046 | **Martin Luther**                 | Protestant · Theologian     | Rare           | Reformation history; explicitly Protestant-tagged while collectible by everyone.                                       | -| 047 | **The Book of Common Prayer**     | Protestant · Item/History   | Uncommon       | Anglican-specific, which also starts proving that "Protestant" doesn't need to be treated as one monolithic tradition. | -| 048 | **Arianism**                      | Controversies · Theology    | Uncommon       | Explain what Arius taught, why Nicaea responded, and what the Nicene position is.                                      | -| 049 | **The Latter-day Saint Movement** | Controversies · Movement    | Uncommon       | Educational treatment; explain where its doctrine of God differs from the app's Nicene standard.                       | -| 050 | **Jehovah's Witnesses**           | Controversies · Movement    | Uncommon       | Same approach: describe rather than mock, then clearly compare with the Nicene baseline.                               | - - - -I'd choose maybe 10 "hero cards" and deliberately make them exercise the most difficult rendering features: - -| Hero card             | Rendering experiment                     | -| --------------------- | ---------------------------------------- | -| Moses                 | emissive/fire                            | -| Elijah                | foil + fire                              | -| Creation              | borderless + large environmental artwork | -| Exodus                | animated/specular water                  | -| Resurrection          | premium holographic treatment            | -| Pentecost             | emissive/fire                            | -| Ark of the Covenant   | metallic gold                            | -| Nicene Creed          | text-heavy card                          | -| Christ Pantocrator    | textured/icon treatment                  | -| Book of Common Prayer | linen/paper/embossing                    | - - - ---- +* Holographic/foil treatment. +* Metal. +* Borderless or other unusual printings. +* Emissive/fire. +* Water/specular effects. +* Text-heavy layouts. +* Linen/paper/embossing. +* Large environmental compositions. ## Initial Technical Direction -The functional requirements should stay mostly engine-independent, but the way the cards look and behave is important enough that we need to consider rendering architecture early. +The functional requirements should stay mostly engine-independent, but the way the cards look and behave is important enough that rendering architecture matters early. -### High-Level Architecture +### High-Level Direction -The web client is the MVP because it gets us something usable on desktop and mobile devices immediately. +The web client is the M1 target because it gives us something usable on desktop and mobile browsers immediately. -Native Android/iOS are still the north star if the project proves worth continuing. +Three.js won the renderer spike and is the current runtime rendering direction for the web client. -```text +The exact surrounding web framework belongs in the technical spec. -                     Backend / API - -                          | - -                  Shared Card Model - -                          | - -               +----------+----------+ - -               |                     | - -          Web Client              Flutter Client - -           (MVP)                 (Android / iOS) - -               |                     | - -        +------+-------+      +------+-------+ - -        |              |      |              | - -    Normal UI     3D Renderer  Flutter UI   3D Renderer - -``` - -The important part is that the **shared card model and backend contracts are renderer-independent**. +The important part is still that the **shared card model remains renderer-independent**. Card identity, artwork references, rarity, finish, material, printing, weight, provenance, etc. should not be encoded as Three.js-specific database structures. ### Card Rendering Contract -The general idea is that the renderer receives a contract describing what the card is rather than the database storing engine-specific details. +The renderer should receive a contract describing what the card is rather than the database storing engine-specific details. Very rough example: ```json - { - -  "cardId": "david-001", - -  "artworkId": "david-a", - -  "finish": "holographic", - -  "material": "metal", - -  "printing": "borderless", - -  "wear": 0.973, - -  "imperfectionSeed": 81251 - + "cardId": "david-001", + "artworkId": "david-a", + "finish": "holographic", + "material": "metal", + "printing": "borderless", + "weightGrams": 128.4 } - ``` -Then the active renderer interprets that contract. +Then Three.js interprets that contract. -That gives us room to use different renderers on different clients without touching the card economy or rewriting the backend model. +That keeps room for another renderer in the future without touching the card economy or rewriting the backend/shared model. -### Asset Pipeline +### Asset / Renderer Direction -Current direction: +The runtime renderer should own the standard card geometry, materials, lighting, card flips, inspection interactions, and pack-opening animation. -```text +Artwork, masks, card metadata, and finish/material/printing selections should remain engine-independent inputs. -Card definition + artwork + masks +Blender is not a production dependency for standard cards or pack openings. It can remain useful for visual exploration, promotional renders, material experiments, or future bespoke 3D assets. -               | +The detailed shader/material architecture has already been explored in the **card-harness spike** and belongs in the technical/art implementation documentation rather than being duplicated here. -               v +Static/pre-rendered thumbnails for binder/search views can be spiked later. I am not married to a particular thumbnail architecture yet; we can optimize that once the main renderer is working. -      Shared visual specification +## Deferred / Separate-Spec Questions - (dimensions, radius, thickness, UVs) - -               | - -       +-------+-------+ - -       |               | - -       v               v - - Three.js renderer  Native renderer - -``` - -The runtime renderer should own the standard card geometry, materials, lighting, wear, inspection interactions, card flips, and pack-opening animation. Standard card geometry is simple and parameterized enough to generate directly from the shared visual specification rather than requiring a `.blend` file or GLB export. - -Artwork, masks, card metadata, and finish/material selections should remain engine-independent inputs. Each renderer interprets those inputs using its own runtime materials and shaders. Holographic, foil, substrate, embossing, wear, and other view-dependent effects must therefore have explicit runtime implementations rather than relying on Blender material-node export. - -Blender is not a production dependency for cards or pack openings. It can remain an optional tool for visual references, promotional renders, material experiments, or future bespoke 3D assets that are not practical to construct procedurally. If such an asset is introduced, it may be exported through glTF/GLB without changing the standard runtime-owned card pipeline. - -### Renderer Technical Spike - Next Step - -The next major technical question should be a renderer spike comparing: - -#### Three.js - -Likely strongest candidate for the web MVP and probably via React Three Fiber if the web application is React-based. - -Things to test: - -* Holographic materials - -* Foil - -* Metal - -* Wear masks - -* Lighting - -* Rotation/zoom/touch controls - -* Pack-opening animation - -* Controlled wrapper tearing, peeling, and crumpling - -* Mobile browser performance - -#### Flutter Scene / Flutter GPU - -Worth testing because native Android/iOS are the eventual goal and a good result here could let us build a native renderer without embedding the web renderer forever. - -Things to test should be the same so we are comparing real output rather than toy demos. - -#### Prototype the Hard Card - -Instead of testing with a plain paper Common card, the technical spike should deliberately build something absurd: - -> Legendary + borderless + metal + holographic + embossed + visible wear - -If both renderers can handle the hardest card nicely, normal cards should be straightforward. - -The spike should compare: - -* Visual fidelity - -* Shader/material flexibility - -* Performance on desktop - -* Performance on mobile - -* Touch interaction quality - -* Developer ergonomics - -* Asset pipeline complexity - -* How much renderer code can realistically be shared between web and future native clients - -We can decide the rendering engine after that rather than locking ourselves in beforehand. - ---- - -## Open Questions / Areas to Workshop - -### Pack Economy - -* How many cards are in the initial login pack? - -* What percentage of the Common pull pool should be filler cards? - -* Are filler odds fixed as a portion of Common pulls, or are they modeled as individual cards with their own weights? - -* Do filler cards count toward the normal compendium / prestige completion? - -* Are filler cards valid inputs for every trade-up / crafting system, or do any systems distinguish between filler and normal Common cards? - -* Does each completed task add cards, improve rarity, improve condition, upgrade the pack, or some mixture? - -* How large should the difference be between an untouched login pack and a fully upgraded daily pack? - -* How do weekly habits such as church attendance affect the pack? - -* Are there separate weekly/monthly packs even though we are moving away from streaks? - -* How quickly should one person be able to complete the base compendium? - -### Rarity / Trade-Ups - -* Exact rarity probabilities. - -* Exact expected supply assuming perfect habit completion. - -* How set-specific rarity works. - -* How many lower-rarity cards are required for a trade-up. - -* Whether finish/material/printing affect trade-up outputs. - -* What happens to destroyed-card provenance. - -### Wear - -* Exact condition bands. - -* Lowest acceptable visual condition. - -* Whether higher rarity cards have condition floors. - -* How heavily wear should affect collectability/value. +A lot of the old open questions are now decided. The things I intentionally do **not** want to force into this functional spec yet are: ### Trinity Card -* Final name. +* Final name / exact theological framing. +* Final Scripture. +* Artwork. +* Exact customization behavior. -* John 3:16 or another verse. +### Content / Art -* Artwork that communicates the idea without bad or irreverent depiction. +* Who ultimately performs doctrinal and historical content review. +* Exact finish taxonomy. +* Exact printing taxonomy and visual definitions. +* Material/finish/printing compatibility exceptions. +* Detailed religious-art/iconography rules. -* How customizable finishes/materials should look. +Those belong primarily in the Master Card Catalog and Art Direction documents. -### Christian Content +### Privacy / Accountability -* Who ultimately reviews doctrinal/content accuracy? +* Exact cryptographic/key-recovery implementation. +* Multi-device encrypted synchronization. +* Detailed accountability permission levels. +* Exact triggers for prayer/accountability signals. -* How deep tradition-specific collections go. +Those belong in the technical/security spec or a later accountability pass. -* Exact taxonomy for controversies / non-Nicene movements. +### Economy / Collection -* How citations/sources are stored and displayed on cards. +* Dedicated filler-card economy. +* Exact finish/material/printing generation probabilities. +* Special pack types. +* Advanced/targeted trade-up recipes. +* Whether population statistics should be globally public. +* Full trading rules. +* Advanced weight/pack-scale mechanics. -### Rendering - -* Three.js vs Flutter Scene technical spike. +### Technical * Exact web framework. +* Static thumbnail architecture. +* Future native renderer/client architecture. +* API/webhook implementation. -* Shader/material design. +The renderer itself is no longer an open question for M1: **Three.js is the current choice**. -* Runtime asset-generation workflow and optional digital content creation tooling. +### Product / Roadmap -* Static thumbnail generation from the same card contract. +* Monetization. +* Native Android/iOS. +* Full multiplayer/social launch. +* Deep prestige/achievement systems. +* Full 500-card production rollout timing. -### Social / Trading - -* When trading belongs in the roadmap. - -* Whether population numbers are public by default. - -* How offers work. - -* Whether destroyed cards remain visible in historical collection records. - -* What parts of a user's binder/profile are private versus public. - -### Privacy - -* Exact client-side encryption model. - -* Whether BYOK is practical for normal users. - -* Account recovery when encryption keys are lost. - -* What anonymous/statistical data can be collected without weakening the privacy model. - -* Exact accountability-sharing permissions. diff --git a/docs/sanctification-master-card-catalog-v20.xlsx b/docs/sanctification-master-card-catalog-v20.xlsx new file mode 100644 index 0000000..6dc5bad Binary files /dev/null and b/docs/sanctification-master-card-catalog-v20.xlsx differ diff --git a/docs/sanctification-tcg-workshop-backlog.md b/docs/sanctification-tcg-workshop-backlog.md index ba05147..a386a0f 100644 --- a/docs/sanctification-tcg-workshop-backlog.md +++ b/docs/sanctification-tcg-workshop-backlog.md @@ -220,6 +220,10 @@ Questions to resolve: A strong candidate direction is to keep negative-habit recovery entirely outside the card economy, but this still needs an explicit decision. +#### ANSWER: + +Negative habits are tracked separately and do not directly affect card rewards. Abstinence, honest reporting, recovery milestones, and returning after failure receive non-economic recognition only. Positive replacement behaviors may be tracked separately as ordinary reward-eligible habits. + --- ### 8. How far back can reward-eligible logging go? @@ -238,6 +242,11 @@ Questions to resolve: - What happens if yesterday's habits are edited after its pack was opened? - Should completed entries become immutable after the reward is claimed? + +#### ANSWER + +Reward-eligible habit completion may be logged through the end of the following calendar day. During this grace period, completion changes may continue to add or remove cards from an unopened daily pack. Opening the pack permanently finalizes its reward state. When the grace period expires, an unopened pack automatically finalizes at its current size and may be saved and opened later. Older habits remain editable for history and consistency purposes but cannot alter card rewards. + --- ## Tier 2 — Economy and Collection Design @@ -256,6 +265,63 @@ Questions to resolve: This needs simulation rather than intuition alone. +#### ANSWER: + +Normal daily and weekly pack slots use the same global rarity distribution. Habit completion increases the number of cards in a pack but does not improve the rarity odds of any individual card. + +##### Pack sizes + +**Daily pack** + +* Login / start of day: 2 cards. +* Each completed reward-eligible daily habit adds 2 cards. +* Up to 5 daily habits may be reward-eligible. +* Maximum daily pack: 12 cards. + +**Weekly pack** + +* No automatic login cards. +* Each completed reward-eligible weekly habit adds 2 cards. +* Up to 3 weekly habits may be reward-eligible. +* Maximum weekly pack: 6 cards. + +Monthly habits do not generate cards in M1. + +##### M1 rarity distribution + +Each card slot independently rolls against the following global rarity table: + +| Rarity | Pull Probability | +| ------------- | ---------------: | +| Common | 59.00% | +| Uncommon | 28.50% | +| Rare | 9.00% | +| Extraordinary | 2.75% | +| Legendary | 0.75% | +| **Total** | **100.00%** | + +These probabilities are separate from the percentage of card identities assigned to each rarity tier. + +The initial 500-card catalog currently contains: + +| Rarity | Card Identities | +| ------------- | --------------: | +| Common | 192 | +| Uncommon | 150 | +| Rare | 101 | +| Extraordinary | 39 | +| Legendary | 18 | +| **Total** | **500** | + +Within a rarity tier, eligible card identities initially have equal probability unless a later set-specific mechanic explicitly defines otherwise. + +Normal packs do not contain guaranteed Rare+, Extraordinary+, or Legendary slots. Every card slot is an independent draw from the same rarity distribution. + +The purpose of completing additional habits is therefore to receive more draws, not better hidden odds. + +These values are the **M1 starting economy** rather than permanent constants. They should be configurable and validated through economy simulation and playtesting. Any future rebalance should remain global and transparent rather than dynamically manipulating probabilities for individual users. + + --- ### 10. What does "about a year to complete the collection" mean? @@ -275,6 +341,31 @@ Questions to resolve: - How much should trade-ups accelerate completion? - What completion target should be used for economy simulation? + +#### ANSWER: + +Collection completion is measured by base card identity. A user needs at least one instance of each base card to complete the base compendium. Finish, printing, material, condition, imperfections, and other instance-level properties do not affect base-compendium completion. + +The M1 economy should not be designed around guaranteeing 100% natural completion after a fixed period. Random duplicates intentionally create a progressively slower collection curve. + +Using the initial 500-card catalog and M1 rarity distribution, the target experience for a maximally consistent user opening normal rewards is approximately: + +50% completion after about 4 weeks. +75% completion after about 9–10 weeks. +90% completion after about 19 weeks. +95% completion after about 28 weeks. +Approximately 99% completion after about one year. + +These are economy-model targets rather than guarantees for individual users. + +The final few percent of the compendium should represent a substantially longer collector challenge when relying only on random packs. Normal pack opening does not provide duplicate protection or dynamically favor cards missing from the user's collection. + +Future mechanics such as trading and trade-ups may provide more directed ways to acquire missing cards and complete the compendium. + +Variant collecting is intentionally much deeper than base-compendium completion. Obtaining one copy of a card identity does not imply that the user has exhausted the collectible possibilities associated with that card. + +Whether filler cards participate in base-compendium completion is decided separately under the filler-card economy. + --- ### 11. How do filler cards work economically? @@ -293,6 +384,11 @@ Questions to resolve: - Whether filler cards can have finish/material/printing variants. - Whether fillers should be visually recognizable as a special classification. + +#### ANSWER + +DEFERRED FOR M1 + --- ### 12. How do trade-ups / crafting work? @@ -316,6 +412,110 @@ Questions to resolve: - Whether destroyed inputs remain visible in provenance history. - Whether trade-ups are the primary card sink or one of several sinks. + +#### ANSWER + +Status: Resolved for M1 + +M1 will include a straightforward 10-to-1 rarity trade-up system. + +A trade-up consumes ten card instances of the same rarity and permanently generates one card of the immediately higher rarity: + +10 Common → 1 Uncommon +10 Uncommon → 1 Rare +10 Rare → 1 Extraordinary +10 Extraordinary → 1 Legendary + +Trade-ups cannot skip rarity tiers, and the rarity upgrade is guaranteed. The random outcome is the resulting card identity and its instance properties, not whether the trade-up succeeds. + +Card identity + +For M1, the resulting card identity is selected randomly from the eligible cards in the next rarity tier. + +The identities of the input cards do not influence the resulting identity. + +Set-specific or category-targeted trade-ups may be considered later but are not part of the initial mechanic. + +Instance-property inheritance + +The finish, material, and printing of the input cards directly influence the corresponding properties of the resulting card. + +Each input contributes an equal share of probability. + +For a ten-card trade-up, each input therefore contributes 10 percentage points to its represented property. + +For example: + +Finish + +10 Holographic +→ 100% Holographic output + +7 Holographic +3 Matte +→ 70% Holographic +→ 30% Matte + +The same system independently applies to material and printing. + +For example: + +Material + +5 Metal +3 Paper +2 Linen + +→ 50% Metal +→ 30% Paper +→ 20% Linen + +and: + +Printing + +8 Normal +2 Borderless + +→ 80% Normal +→ 20% Borderless + +Each property is rolled independently. This means a trade-up can produce combinations that were not present on any single input card. + +For example, five Holographic/Paper cards and five Matte/Metal cards could potentially produce a Holographic/Metal result. + +This is intentional. Trade-ups act as a way to combine desirable collectible traits as well as increase rarity. + +Guaranteed property inheritance + +If all ten input cards share a property, that property is guaranteed on the resulting card. + +Examples: + +10 Holographic cards → guaranteed Holographic. +10 Metal cards → guaranteed Metal. +10 Borderless cards → guaranteed Borderless. +Inputs sharing all three properties guarantee all three properties on the output. + +This allows collectors to deliberately construct trade-up recipes rather than treating every duplicate of the same rarity as economically identical. + +Destruction + +All ten input cards are permanently consumed by the trade-up and are no longer part of the active card population or any user's collection. + +Their historical provenance should not be erased. We could have a counter of how many of the different cards you have traded up but we don't want to have a huge DB of dead cards. So a basic counter + +Design intent + +Trade-ups serve two purposes: + +Provide a meaningful use for duplicates and permanently remove cards from circulation. +Allow low-rarity cards with desirable finishes, materials, printings, or condition to retain meaningful collector and crafting value. + +A highly desirable Common card is therefore not automatically disposable merely because its base identity is Common. Its instance properties can make it valuable either as a collectible or as an ingredient in a deliberately constructed trade-up. + +More advanced crafting systems, targeted card identities, targeted sets, and additional recipes are deferred until after the M1 economy has been tested. + --- ### 13. What role does voluntary card burning actually serve? @@ -338,6 +538,12 @@ Questions to resolve: - Does a burned card remain visible in historical records? - Is the religious symbolism appropriate and useful, or is this simply an economy mechanic with a dramatic animation? +#### ANSWER + +I think for now it would be a nice animation for the card trade up. Like the 10 cards burn together to form the new one + +Other aspects we can defer. + --- ### 14. When are saved-pack contents determined? @@ -356,6 +562,42 @@ Questions to resolve: The implementation should be deterministic enough that server/client behavior cannot accidentally reroll contents. + +#### ANSWER + +The contents of a pack are generated when that pack is finalized, not when it is opened. + +Finalization occurs when: + +The user opens an active pack early. +The pack has reached its maximum possible reward state. +The pack's reward-eligible grace period expires. + +When a pack finalizes, the system determines and stores all resulting card instances, including: + +Card identity. +Rarity. +Finish. +Material. +Printing. +Condition. +Any other instance-level properties enabled by the current economy. + +Opening a pack does not generate or reroll its contents. It only reveals card instances that were already created when the pack finalized. + +This ensures that: + +Saved packs are not affected by future rarity or economy rebalancing. +Opening cannot be retried to obtain different results. +Client interruptions during the reveal process cannot change awarded cards. +Population and provenance can be tracked consistently. + +A saved unopened pack may therefore remain unopened indefinitely without changing its contents. + +Card provenance should distinguish between the time an instance was created/finalized and the time it was actually revealed to the user. + +For an active pack opened before its reward period ends, confirming the open action first finalizes the pack at its current card count, generates its contents, and then begins the reveal experience. + --- ## Tier 3 — What Constitutes a Card @@ -383,6 +625,20 @@ Questions to resolve: - Presentation. - Whether filler remains outside the rarity hierarchy. +#### ANSWER + +Common +Uncommon +Rare +Extraordinary +Legendary + +Card rarity = identity prominence / pull scarcity + +Instance rarity = not a separate thing + +Finish/material/printing/condition = independent collectible properties + --- ### 16. What does rarity mean across different sets? @@ -399,6 +655,13 @@ Questions to resolve: - Does a card identity have one canonical rarity or can each printing/set version have its own? - How should rarity be communicated so users do not interpret it as spiritual importance? + +#### ANSWER + +A Legendary card therefore participates in the same Legendary rarity tier regardless of whether it represents a Biblical person, historical event, doctrine, tradition-specific subject, or another category. + +Sets do not define independent rarity probabilities in M1. + --- ### 17. Are finish, printing, and material fully combinatorial? @@ -424,6 +687,13 @@ Questions to resolve: - Do special combinations have different population tracking? - Should invalid combinations be encoded as rules or simply never generated? + +#### ANSWER + +Finish, material, and printing are independent collectible properties and are combinatorial by default. + +Unusual combinations are not considered invalid merely because they would be uncommon or difficult to manufacture as physical cards. Sanctification TCG is a digital collectible system, and unusual combinations are desirable when the renderer can present them convincingly. + --- ### 18. What exactly is a printing? @@ -448,6 +718,11 @@ Questions to resolve: - Whether alternate artwork is itself a printing or a separate artwork property. - Whether tradition-specific versions are printings, separate cards, or artwork variants. + +#### ANSWER + +Printing is an independent collectible property that may materially alter a card’s artwork composition, framing, layout, or text treatment. Exact printing types, visual definitions, compatibility rules, and art-production requirements are defined in the Art Direction specification. + --- ### 19. What is the finish taxonomy? @@ -470,6 +745,10 @@ Questions to resolve: - Are finish variants purely visual, or do they also affect rarity/value? - Do finishes alter simulated weight? +#### ANSWER + +Will be in the art direction + --- ### 20. What is the material taxonomy? @@ -493,6 +772,10 @@ Questions to resolve: - Edge wear behavior. - Whether unusual materials should be extremely rare or simply another independent variant dimension. +#### ANSWER + +Each card instance has exactly one material representing its physical substrate. M1 candidate materials are Paper, Linen, Wood, and Metal. Material is independent of base rarity, participates in card generation, population grouping, and trade-up inheritance, and may have its own economy probabilities. Exact visual treatment, physical appearance, compatibility constraints, thickness, lighting response, and renderer behavior are defined in the Art Direction specification. Paper would be considered the default + --- ### 21. How does condition / wear work? @@ -516,6 +799,18 @@ Questions to resolve: - How much condition should affect collector desirability. - Lowest acceptable visual state. +#### ANSWER + +Randomized wear or condition will not be an instance-level collectible property in M1. + +Earlier designs assigned every card a continuous condition value and rendered varying levels of scratches, edge wear, scuffs, and other damage. This creates undesirable negative variance: a user may obtain a rare card identity with exactly the finish, material, and printing they wanted, only for the card to feel inferior because of an additional condition roll. + +Consider maybe some other finishes: +Weathered +Antiqued +Distressed parchment +Patinated metal + --- ### 22. Do imperfections belong in the product? @@ -536,6 +831,10 @@ Questions to resolve: - Do users need a way to inspect/identify them explicitly? - Should the MVP omit them until the rest of the variant system is proven? +#### ANSWER + +CUT + --- ### 23. How should population be counted? @@ -548,8 +847,6 @@ Questions to resolve: - Whether artwork belongs in the population key. - Whether set belongs in the population key. -- Whether condition should remain excluded. -- Whether imperfections remain excluded. - Whether population numbers are: - Global. - Public. @@ -561,6 +858,11 @@ Questions to resolve: - Total ever opened. - Both. + +#### ANSWER + +Population is tracked for meaningful collectible variants using card identity + artwork variant + finish + material + printing. The system records both total instances ever created and the number currently surviving. Burning and trade-ups reduce surviving population but do not erase historical creation counts. Provenance, ownership, and timestamps do not create separate populations. Public visibility of population statistics is decided separately. + --- ### 24. Does simulated weight stay in the product? @@ -581,6 +883,11 @@ Questions to resolve later: This is intentionally low priority. + +#### ANSWER + +Each card instance has a simulated physical weight derived primarily from its material, with small deterministic variation and optional minor adjustments from finish/printing. + --- ## Tier 4 — The Trinity Card @@ -608,6 +915,10 @@ Questions to resolve: - Another title. - Whether this is technically a collectible card, foundation card, or a separate object type. +#### ANSWER + +Deferred for now + --- ### 26. Which Scripture belongs on the Trinity card? @@ -627,6 +938,10 @@ Questions to resolve: - God's love and salvation? - Whether a verse is sufficient or a short doctrinal text is more appropriate. +#### ANSWER + +Deferred for now + --- ### 27. What should the Trinity artwork depict? @@ -644,6 +959,10 @@ Questions to resolve: - Whether any traditional Trinitarian iconography is appropriate. - How to avoid accidentally privileging one tradition's visual theology. +#### ANSWER + +Deferred for now + --- ### 28. How customizable is the Trinity card? @@ -663,6 +982,10 @@ Questions to resolve: - Whether customization changes provenance. - Whether a customized Trinity card remains one canonical instance. +#### ANSWER + +Deferred for now + --- ## Tier 5 — Christian Content and Editorial Policy @@ -679,6 +1002,25 @@ Questions to resolve: - Is the application presenting a doctrinal position or merely using one as an organizational framework? - What happens with traditions that accept the Nicene Creed but interpret later doctrines very differently? +#### ANSWER + +Sanctification TCG uses Nicene Christianity as its doctrinal baseline. + +For the purposes of the application, beliefs and movements that fall outside the core theology expressed by the Nicene Creed are considered outside orthodox Christianity and may be identified as heretical or non-Nicene where historically and theologically appropriate. + +This boundary is not intended to prevent the application from representing or teaching about beliefs outside it. Non-Nicene movements, historical heresies, disputed teachings, and other religious traditions may still appear in the card catalog when they are educationally or historically relevant. + +Their treatment should remain accurate, charitable, and informative. Cards should explain: + +What the person, movement, or teaching actually believed. +Its historical and theological context. +Why it was or is considered outside the Nicene standard. +How its beliefs differ from the doctrinal baseline used by the application. + +The goal is clarity without mockery. The application does not need to present every theological position as equally compatible with Christianity in order to describe those positions fairly. + +Disagreements that exist within Nicene Christianity should be distinguished from teachings that cross the Nicene boundary. Catholic, Orthodox, Protestant, and other Nicene traditions may disagree substantially on later doctrines and practices while remaining inside the application's shared Christian baseline. + --- ### 30. How deep do tradition-specific collections go? @@ -709,6 +1051,10 @@ Questions to resolve: - All of the above. - Whether cards can belong to multiple traditions. +#### ANSWER + +Already completed in master catalog + --- ### 31. How are controversies and non-Nicene movements classified? @@ -736,6 +1082,27 @@ Questions to resolve: - How labels are chosen without turning cards into insult cards. - Who approves wording. +#### ANSWER + +* Intra-Nicene controversy — disputes between traditions that remain inside the doctrinal baseline. +* Historical heresy — teachings historically condemned as contrary to core Christian doctrine. +* Schism / ecclesial controversy — primarily disputes over communion, authority, jurisdiction, or church structure rather than rejection of Nicene doctrine. +* Non-Nicene movement — later movements or religious bodies whose theology falls outside the Nicene baseline. + +Cards dealing with controversial teachings or movements should be educational rather than insulting or polemical. + +Where practical, their content should explain: + +What the person, movement, or teaching actually believed or taught. +Its historical context. +Why the issue became controversial. +Whether the disagreement falls inside or outside the application's Nicene baseline. +How the Nicene position differs when the subject falls outside that baseline. + +Disagreements within Nicene Christianity should be represented as genuine disagreements without implying that one side automatically falls outside Christianity unless the underlying issue actually crosses the Nicene boundary. + +The goal is doctrinal clarity, historical accuracy, and charitable presentation. + --- ### 32. Who reviews doctrinal and historical accuracy? @@ -754,6 +1121,10 @@ Questions to resolve: - How disagreements between credible traditions are presented. - Whether the app should explicitly distinguish consensus from disputed interpretation. +#### ANSWER + +Deferred for now. When we get to the writing, we'll deal with it then + --- ### 33. What is the citation/source policy? @@ -773,6 +1144,77 @@ Questions to resolve: - How sources are versioned if card text changes. - Whether citations appear directly on the card or only in the detailed information panel. +#### ANSWER + +Card content should be supported by source metadata sufficient for users to understand where factual, historical, scriptural, and doctrinal claims come from. + +The card face should remain visually concise. Full citations and supporting resources should generally appear in the card's expanded information or learning panel rather than crowding the primary card layout. + +Source priority + +Where applicable, sources should be prioritized approximately as follows: + +Scripture, when directly relevant. +Primary historical or theological sources, such as creeds, council texts, letters, sermons, confessions, catechisms, or writings by the person being represented. +Tradition-specific authoritative sources, when explaining the teaching of a specific Christian tradition. +Reputable secondary historical or scholarly sources, for historical context, chronology, interpretation, or claims that cannot be established from a primary source alone. + +Research tools, general websites, and tertiary summaries may assist content creation but should not normally serve as the sole published authority for important doctrinal or historical claims when stronger sources are available. + +Card-face references + +The card itself may contain concise references when useful, such as: + +Scripture citations. +Short quotation attribution. +Council/date references. +Author/work references. + +Full bibliographic details are not required on the visible card face. + +Expanded information panel + +The expanded card view should provide access to relevant source information and further reading. + +This may include: + +Scripture references. +Primary-source citations. +Historical references. +Tradition-specific documents. +Secondary sources. +Further-reading links or resources. +Quotations + +Direct quotations require clear attribution. + +Where practical, quote metadata should include: + +Author or speaker. +Source work. +Relevant section, chapter, paragraph, or page. +Translation or edition where the wording depends materially on it. + +Quotation attribution should be verified before publication. + +Disputed claims + +When historical, theological, or attribution questions are genuinely disputed, the application should represent that uncertainty rather than presenting one interpretation as uncontested fact. + +The information panel may identify: + +Traditional attribution. +Scholarly disagreement. +Uncertain dates. +Different interpretations among Nicene traditions. +Data model + +Sources belong primarily to the card definition, not the individual card instance. + +Finish, material, printing, provenance, and other instance-level properties do not normally change the underlying citation set. + +A future alternate printing or artwork version that contains materially different text may reference additional or different sources where necessary. + --- ### 34. What religious-art representation rules are needed? @@ -799,6 +1241,24 @@ Questions to resolve: - What requires theological/art review. - Whether some concepts should use symbolic rather than figurative art. +#### ANSWER + +**Status: Deferred primarily to Art Direction** + +Detailed rules for depicting Jesus, the Father, the Holy Spirit, angels, saints, icons, biblical figures, crucifixion scenes, resurrection scenes, and other sacred subjects belong in the Art Direction specification. + +The functional specification should retain only the following principles: + +* Religious artwork should be treated respectfully and intentionally. +* The application should avoid irreverent, trivializing, or unnecessarily sensational depictions of sacred subjects. +* Where Christian traditions have materially different artistic conventions, the application may use tradition-specific visual treatments rather than forcing every subject into one universal style. +* Symbolic or non-figurative representation may be used where a literal depiction would be inappropriate, uncertain, or theologically sensitive. +* Historical or traditional iconography may inform artwork when appropriate to the card and collection. +* Artistic representation should not imply greater spiritual worth based on card rarity. +* Exact depiction rules, iconographic references, prohibited treatments, and tradition-specific art guidance are maintained in the Art Direction document. + +The Trinity/Foundation card remains a special case whose exact imagery is deferred separately. + --- ## Tier 6 — Privacy, Accountability, and Social @@ -828,6 +1288,14 @@ Questions to resolve: This will likely require a dedicated technical-spec decision after the functional expectations are settled. +#### ANSWER + +Sensitive user-authored habit content is encrypted client-side before being stored or synchronized. The backend stores only the minimum unencrypted metadata necessary to operate schedules, rewards, accounts, and explicitly initiated social features. The server should not possess the information necessary to casually inspect private habit names, negative habits, notes, or detailed accountability content. + +Negative-habit content and history should receive the strongest protection because they do not participate in the card economy and may contain particularly sensitive information. + +The exact cryptographic design, key wrapping, multi-device synchronization, and recovery mechanism belong in the technical/security specification. BYOK is cut. Account recovery must not silently undermine the stated privacy guarantees. + --- ### 36. What are the exact accountability permissions? @@ -853,6 +1321,10 @@ Questions to resolve: - Whether access is permanent or temporary. - Whether users can revoke previously-shared information. +#### ANSWER + +DEFERRED FOR NOW + --- ### 37. What triggers an accountability / prayer signal? @@ -870,6 +1342,10 @@ Questions to resolve: Privacy suggests manual or explicitly-configured behavior rather than implicit disclosure. +#### ANSWER + +DEFERRED FOR NOW + --- ### 38. What collection metadata is public? @@ -879,21 +1355,29 @@ Potential metadata: - Binder. - Card ownership. - Finish/material/printing. -- Condition. - Original opener. - Opening date. - Population. - Trade history. -- Destroyed-card history. Questions to resolve: - Default visibility. - Per-user privacy controls. -- Whether original-opener identity persists after trading. -- Whether users can anonymize provenance. -- Whether population data is public globally. -- Whether destroyed cards remain visible. +- Whether original-opener identity persists after trading. - YES (nice to know who generated) +- Whether users can anonymize provenance. - NO +- Whether population data is public globally. - DEFER +- Whether destroyed cards remain visible. - NO + +#### ANSWER + +- Binder. +- Card ownership. +- Finish/material/printing. +- Original opener. +- Opening date. +- Population. +- Trade history. --- @@ -916,6 +1400,10 @@ Questions to resolve later: Trading is intentionally outside the first MVP but the data model may need to leave room for it. +#### ANSWER + +DEFERRED FOR NOW + --- ## Tier 7 — MVP and Technology @@ -955,6 +1443,10 @@ Questions to resolve: The current scope reads more like a vertical slice designed to prove the unique and technically difficult parts of the product. +#### ANSWER + +I'll iron this out as I go. I want it to be no multiplayer but fully locally functioning with like 50 cards. + --- ### 41. How many real cards does the first build actually need? @@ -962,7 +1454,7 @@ The current scope reads more like a vertical slice designed to prove the unique Current target: - Around 50 real cards. -- Plus 4–6 filler cards, possibly inside or outside that count. +- Plus 4–6 filler cards, possibly inside or outside that count. - DEFERRED Questions to resolve: @@ -970,10 +1462,15 @@ Questions to resolve: - Could the first implementation use: - 10 hero cards. - A smaller representative common/uncommon pool. - - Filler cards. - When does the full ~50-card MVP set become necessary? - Should content production happen after the hard renderer is proven? +#### ANSWER + +50 For now + +a few of each category. + --- ### 42. Three.js vs Flutter Scene / Flutter GPU @@ -1000,6 +1497,10 @@ The proposed hard test card is: This decision should follow the spike rather than be made theoretically. +#### ANSWER + +Already decided. Three.js wins + --- ### 43. What is the exact web framework? @@ -1019,6 +1520,10 @@ Questions to resolve after the renderer spike: - Mobile-browser architecture. - Whether the renderer lives as an isolated package. +#### ANSWER + +FOR TECH SPEC + --- ### 44. What is the shader/material architecture? @@ -1043,6 +1548,10 @@ Still unresolved: - Performance tiers. - How closely multiple renderer implementations must match visually. +#### ANSWER + +This is worked out in the card-harness spike + --- ### 45. How are static thumbnails generated? @@ -1060,6 +1569,10 @@ Questions to resolve: - How are holographic cards represented in static form? - Can the same card contract drive both 3D and thumbnail generation? +#### ANSWER + +Can be spiked out later. I'm not married to the idea. I can always optimize later + --- # Explicitly Deferred Topics diff --git a/spikes/card-harness/CURRENT-DEFAULTS.md b/spikes/card-harness/CURRENT-DEFAULTS.md index 0fe5354..24ae973 100644 --- a/spikes/card-harness/CURRENT-DEFAULTS.md +++ b/spikes/card-harness/CURRENT-DEFAULTS.md @@ -6,10 +6,19 @@ - Approved: September 7, 2026 - Scope: card geometry, front-face materials, substrate distinction, foil, holographic response, and current finish-mask policy - Follow-up approval: rigid pack prototype and artifact-free individual card inspection; retained as the foundation for the foil iteration below +- Linen follow-up: `linen-relief-v1-2026-09-10`, visually approved September 10, 2026 +- Metal and wood follow-up: `metal-wood-relief-v1-2026-09-10`, visually approved September 10, 2026 - Not yet approved: the new foil treatment/choreography, lighting presets, mobile performance, or wear These values represent the current visual baseline. Changes should be deliberate and compared against this revision rather than treated as incidental shader cleanup. +The approved September 10 depth follow-ups supersede the v4 linen, metal, and wood +surface responses below: raised-looking weave, recessed metal etching, and raised +wood grain with shader parallax, including Printed ink at oblique angles. +Paper, Plastic, card geometry, backs, and renderer settings retain the v4 baseline. +This visual approval is not +a mobile performance sign-off or approval of the remaining pack choreography. + ## Geometry The card is generated procedurally at runtime. Blender and GLB files are not required. @@ -128,10 +137,21 @@ Finish and material are independent concepts, but combinations should exist only - Roughness floor: `0.70` - Interlaced thread frequency: `52 x 73` -- Relief amplitude: `0.005 * detail` -- Reflection strength: `0.025` +- Raised weave height span: `0.010 * min(detail, 1.5)` scene units +- Analytic thread slopes keep normal response readable at oblique angles +- Two procedural weave evaluations add bounded view-dependent parallax; artwork, + finish-mask, and wear UVs stay anchored +- Grazing denominator floor: `0.30`; detail is capped at `1.5` for linen relief +- Subpixel threads fade using screen-space derivatives +- Base reflection strength: `0.025`; broad thread-ridge highlight strength: + `0.025 + 0.055 * grazing`, modulated by resolved ridge height and surface detail - Edge: `#a89772`, roughness `0.78`, metalness `0`, clear coat `0` +This is shader relief, not displaced geometry: the card silhouette and physical +thickness are unchanged. The Surface detail control scales the effect, including +turning it off at zero. Printed ink retains the thread lighting without requiring +a foil finish; foil and holographic coating formulas are unchanged. + ### Plastic - Roughness ceiling: `0.25` @@ -142,9 +162,13 @@ Finish and material are independent concepts, but combinations should exist only ### Metal +- Visually approved September 10, 2026 - Roughness ceiling: `0.20` - Artwork-driven etch: darker values are recessed more deeply -- Etch relief amplitude: `-0.0065 * depth * min(detail, 1.5)` +- Etch relief amplitude: `-0.010 * depth * min(detail, 1.5)` (previously `-0.0065`) +- Bounded predictor/corrector parallax shifts artwork and finish mask together; + grazing denominator floor `0.35`, fading within `0.025` UV of the card edges +- Directional recess shadow strength: `0.22`, scaled by detail and local ridge depth - Cavity shading: `0.12` on the metal underprint and `0.06` on the combined printed result - Metal underprint blend: `0.42` - Edge: `#a8adb7`, roughness `0.20`, metalness `0.86`, clear coat `0.18` @@ -158,11 +182,18 @@ Metal + Foil is a special intentional combination: - Plating weight is capped at `0.34` - Must not turn the illustration into opaque gold +Parallax and directional recess shading add depth cues beyond the stronger relief. +The existing underprint tint and foil/plating recipes are retained; the finish +mask follows the shifted artwork. Card thickness and geometry are unchanged. + ### Wood +- Visually approved September 10, 2026 - Roughness floor: `0.48` - Warped longitudinal grain frequency: `170` -- Relief amplitude: `0.007 * detail` +- Relief amplitude: `0.012 * min(detail, 1.5)` (previously `0.007 * detail`) +- Two procedural grain evaluations provide bounded parallax; grazing denominator + floor `0.35`. Artwork stays anchored; tint and relief use the same shifted grain. - Warm tint blend: `0.25` - Reflection strength: `0.09` - Edge: `#6b3f21`, roughness `0.56`, metalness `0`, clear coat `0.08` diff --git a/spikes/card-harness/README.md b/spikes/card-harness/README.md index 89658af..01fe906 100644 --- a/spikes/card-harness/README.md +++ b/spikes/card-harness/README.md @@ -66,6 +66,12 @@ Camera framing eases with the lift/exit so the deeper separation does not unexpectedly enlarge the card. The edge mesh draws only extrusion side walls, not end caps beneath the dedicated artwork faces, preventing depth-fighting bands at farther zoom distances. Pack assets are loaded separately on first entry, with retry on failure. +Before enabling pack interaction, textures are uploaded and shaders are compiled +for the pouch, hidden card fronts, universal backs, and both neutral/revealed edge +materials using the scene's lighting and environment. This preparation does not +draw or reveal cards. The loading state lasts until it finishes; failures release +the new pack resources and keep retry available. Returning to an already prepared +pack retains its state without repeating this preparation. Wrapper deformation is coalesced to once per rendered frame, including touch dents and tear input. Fixed crease/crimp calculations are cached; unchanged pouch surfaces @@ -73,6 +79,9 @@ and ribbons do not recalculate normals/bounds or upload vertex buffers. Hidden wrappers do not deform. Pack UI updates are also coalesced and only write changed values. These optimizations retain the same mesh resolution, deformation formulas, materials, lighting, pixel ratio, and antialiasing. +Mint cards (`condition = 1`) bypass procedural wear calculations whose contribution +is zero. Worn cards use the original wear equations, and all finish/substrate +shading remains unchanged. There is no sound, haptics, particles, cloth simulation, backend, rewards persistence, pack progress persistence, or timeline/editor. This is a choreography proof, not an @@ -86,12 +95,26 @@ Finish and substrate are separate: - Finish controls printed ink, foil, or holographic coating. - Substrate controls surface roughness/microtexture, underprint response, and edge/core appearance. -Paper uses fine, irregular fibers and a broad, weak highlight. Linen uses raised -interlaced threads with small shaded recesses. Plastic has a smooth clear-coat -highlight even in Printed ink mode. Wood uses warped longitudinal grain with -surface relief and a restrained warm tint. Metal retains its reflective -underprint and adds a shallow luminance-driven etch, with darker artwork -recessed slightly more than lighter artwork. Procedural detail fades below +Paper uses fine, irregular fibers and a broad, weak highlight. Linen uses raised-looking +interlaced threads with analytic slope lighting, a bounded two-sample procedural +parallax offset, and broad ridge highlights that remain visible in Printed ink +at oblique angles. Only the weave shifts with the view: artwork and lettering stay +anchored. Surface detail scales the effect; unresolved threads fade to avoid +shimmer. This is shader relief with no added geometry or change to the card's +silhouette, back, or thickness. This linen look was visually approved September 10, +2026 as `linen-relief-v1-2026-09-10`; its exact defaults are recorded in +[`CURRENT-DEFAULTS.md`](CURRENT-DEFAULTS.md#linen). No extra toggle or configuration +is required: selecting Linen applies it in Inspect, Lab, and the authored pack. +Plastic has a smooth clear-coat +highlight even in Printed ink mode. Wood uses raised longitudinal grain with +bounded procedural parallax, stronger relief, and a restrained warm tint. +The artwork stays anchored while grain shading and tint shift together. +Metal retains its reflective underprint and uses luminance-driven recesses: +bounded parallax shifts the artwork and finish mask together, with directional +groove shading to emphasize depth. The effect fades at the card edges. +Metal and wood depth were visually approved September 10, 2026 as +`metal-wood-relief-v1-2026-09-10`. Surface detail scales the relief; neither +material adds geometry or changes the card silhouette. Procedural detail fades below pixel resolution to limit shimmer. The universal card back uses fixed neutral material properties so front finish and substrate choices do not spoil a pack reveal. @@ -107,6 +130,11 @@ slightly raised champagne-metal polishing over the etched steel base. - `blender_prototype/card_finish_material.py` - `blender_prototype/render_premium_review.py` +The approved linen, metal, and wood depth follow-ups change runtime shader behavior, +not the source artwork, generated normal map, or Blender reference renders. The +asset manifest therefore retains its v4 reference revision; the scoped material +approvals and parameters are tracked in `CURRENT-DEFAULTS.md`. + The runtime shader and geometry are runtime implementations, not a claim of pixel-identical Blender output. Live review removed only David's face, hand, and lamb ellipse cutouts. Color-based coverage and title-panel protection remain. Holographic color and angle response are preserved; reduced sheen applies only to foil. @@ -131,8 +159,32 @@ npm test These check exact pre-optimization wrapper geometry snapshots, surface update counts, per-frame input batching, pause/resume, pinch handoff, restart, reduced -motion, and ordered reveals. They are CPU/state checks, not a real-device GPU or -touch-latency benchmark; mobile performance still needs on-device validation. +motion, ordered reveals, GPU preparation, and failure cleanup. They are CPU/state +checks, not a real-device GPU or touch-latency benchmark; mobile performance still +needs on-device validation. + +WebGL pixel parity and pack shader-preparation checks require a local headless +Chromium browser with WebGL2 support, installed project dependencies, and generated +reference assets (`npm run assets`): + +```sh +SHADER_BROWSER=/absolute/path/to/chromium \ +SHADER_BROWSER_KIND=chromium \ +node scripts/shader-pixel-regression.mjs +``` + +The Chromium runner uses SwiftShader software WebGL. Browsers are not installed +automatically; a missing browser, missing WebGL support, timeout, pixel mismatch, +or unexpected shader program causes a nonzero exit rather than a skipped check. + +The pixel check compares 360 combinations of substrate, finish, condition, angle, +and lighting against the current shader with unconditional wear evaluation, +verifying that the mint fast path remains equivalent. A separately hash-verified +v4 reference checks that Paper and Plastic remain unchanged and that the approved +Linen, Metal, and Wood iterations produce a visual difference. The pack check exercises opening, +all three reveals, and restart under point, directional, and spot lighting, +checking for new shader programs after preparation. Software WebGL can validate +pixel parity and program reuse, but its timings are not mobile GPU benchmarks. The asset-generation script uses cross-platform Node APIs and works on Windows and Linux. The retained `export:blender` script is an optional reference utility and is not part of the application pipeline. diff --git a/spikes/card-harness/scripts/pack-performance.test.mjs b/spikes/card-harness/scripts/pack-performance.test.mjs index b3099c5..71547cb 100644 --- a/spikes/card-harness/scripts/pack-performance.test.mjs +++ b/spikes/card-harness/scripts/pack-performance.test.mjs @@ -231,3 +231,114 @@ test('reduced-motion opening keeps the existing 70 ms transition duration', (t) step(1) assert.equal(pack.state, 'stackReady') }) + +test('GPU preparation uploads unique textures and compiles hidden fronts and both edge looks', async (t) => { + const { pack, canvas } = setupPack(t) + pack.setActive(false) + const scene = new THREE.Scene() + scene.environment = pack.options.environment + const backTexture = new THREE.Texture() + pack.options.backMaterial.map = backTexture + const uploaded = [] + const compiledEdges = [] + let reveals = 0 + canvas.addEventListener('packreveal', () => reveals++) + const before = geometryHash(pack.wrapper) + let finishCompile + const firstCompile = new Promise((resolve) => { finishCompile = resolve }) + const renderer = { + initTexture(texture) { uploaded.push(texture) }, + async compileAsync(root, camera, targetScene) { + assert.equal(root, pack.root) + assert.equal(camera, pack.camera) + assert.equal(targetScene, scene) + assert.equal(root.visible, false) + assert.equal(pack.state, 'sealed') + assert.equal(pack.cards.some((card) => card.front.visible), false) + assert.ok(pack.cards.every((card) => root.getObjectById(card.front.id))) + compiledEdges.push(pack.cards.map((card) => card.edge.clearcoat)) + if (compiledEdges.length === 1) await firstCompile + }, + } + let ready = false + const preparation = pack.prepareGPU(renderer, scene).then(() => { ready = true }) + await Promise.resolve() + assert.equal(ready, false) + finishCompile() + await preparation + assert.equal(uploaded.length, new Set(uploaded).size) + assert.equal(uploaded.length, 9, '4 artwork/mask textures, normal, environment, back and 2 wrapper maps') + for (const { artwork, mask } of Object.values(pack.options.textures)) { + assert.ok(uploaded.includes(artwork)) + assert.ok(uploaded.includes(mask)) + } + assert.ok(uploaded.includes(backTexture)) + assert.ok(uploaded.includes(pack.options.normalMap)) + assert.ok(uploaded.includes(scene.environment)) + assert.deepEqual(compiledEdges, [[0.02, 0.02, 0.02], [0, 0.02, 0.18]]) + assert.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02]) + assert.equal(geometryHash(pack.wrapper), before) + assert.equal(reveals, 0) +}) + +test('failed reveal-material warmup restores neutral edges and can be retried', async (t) => { + const { pack } = setupPack(t) + pack.setActive(false) + let compilations = 0 + const failure = new Error('GPU compilation failed') + const renderer = { + initTexture() {}, + async compileAsync() { + if (++compilations === 2) throw failure + }, + } + const scene = new THREE.Scene() + await assert.rejects(pack.prepareGPU(renderer, scene), (error) => error === failure) + assert.deepEqual(pack.cards.map((card) => card.edge.clearcoat), [0.02, 0.02, 0.02]) + assert.equal(pack.root.visible, false) + assert.equal(pack.state, 'sealed') + await pack.prepareGPU(renderer, scene) + assert.equal(compilations, 4) +}) + +test('GPU upload errors propagate and preparation cannot mutate an active pack', async (t) => { + const { pack } = setupPack(t) + const failure = new Error('GPU upload failed') + let uploads = 0 + const renderer = { + initTexture() { uploads++; throw failure }, + async compileAsync() { assert.fail('Compilation must not run after upload failure') }, + } + const scene = new THREE.Scene() + await assert.rejects(pack.prepareGPU(renderer, scene), /before activating/) + assert.equal(uploads, 0) + pack.setActive(false) + await assert.rejects(pack.prepareGPU(renderer, scene), (error) => error === failure) +}) + +test('pack cleanup disposes owned resources without disposing shared or caller-owned textures', (t) => { + const { pack } = setupPack(t) + const scene = new THREE.Scene() + scene.add(pack.root) + const shared = [ + pack.options.backMaterial, pack.options.normalMap, pack.options.environment, + ...Object.values(pack.options.textures).flatMap(({ artwork, mask }) => [artwork, mask]), + ] + const sharedSpies = shared.map((resource) => t.mock.method(resource, 'dispose')) + const geometries = new Set() + const materials = new Set() + pack.root.traverse((object) => { + if (!(object instanceof THREE.Mesh)) return + geometries.add(object.geometry) + materials.add(object.material) + }) + materials.delete(pack.options.backMaterial) + const resources = [...geometries, ...materials, + ...new Set(pack.wrapper.root.children.map((mesh) => mesh.material.map))] + const ownedSpies = resources.map((resource) => t.mock.method(resource, 'dispose')) + pack.dispose() + assert.equal(pack.root.parent, null) + assert.equal(pack.root.visible, false) + assert.ok(ownedSpies.every((spy) => spy.mock.callCount() === 1)) + assert.ok(sharedSpies.every((spy) => spy.mock.callCount() === 0)) +}) diff --git a/spikes/card-harness/scripts/shader-pack-preparation.mjs b/spikes/card-harness/scripts/shader-pack-preparation.mjs new file mode 100644 index 0000000..a3a378e --- /dev/null +++ b/spikes/card-harness/scripts/shader-pack-preparation.mjs @@ -0,0 +1,127 @@ +import * as THREE from 'three' +import { PackOpening, packContents } from '../src/packOpening.ts' +import { createStudioCubeTexture } from '../src/cardMaterial.ts' + +export async function verifyPackPreparation() { + const startedAt = performance.now() + const loader = new THREE.TextureLoader() + const paths = ['david-front', 'david-finish-mask', 'timothy-front', 'timothy-finish-mask', 'card-back', 'linen-normal'] + const textures = await Promise.all(paths.map((path) => loader.loadAsync(`/reference/${path}.png`))) + for (const index of [0, 2, 4]) textures[index].colorSpace = THREE.SRGBColorSpace + textures[5].wrapS = textures[5].wrapT = THREE.RepeatWrapping + const environment = createStudioCubeTexture() + const results = [] + try { + for (const lightType of ['Point', 'Directional', 'Spot']) { + // A fresh context prevents the preceding pixel test from accidentally warming pack shaders. + const renderer = new THREE.WebGLRenderer({ antialias: false }) + renderer.setSize(192, 264) + renderer.outputColorSpace = THREE.SRGBColorSpace + renderer.toneMapping = THREE.ACESFilmicToneMapping + const shaderErrors = [] + renderer.debug.onShaderError = (gl, program, vertex, fragment) => { + shaderErrors.push([gl.getProgramInfoLog(program), gl.getShaderInfoLog(vertex), + gl.getShaderInfoLog(fragment)].join('\n')) + } + const scene = new THREE.Scene() + scene.background = new THREE.Color('#0b0e14') + scene.environment = environment + const lightPosition = new THREE.Vector3(2.2, 2.5, 4.4) + const light = lightType === 'Point' ? new THREE.PointLight('#fff0d0', 34, 20, 1.7) + : lightType === 'Directional' ? new THREE.DirectionalLight('#fff0d0', 5.4) + : new THREE.SpotLight('#fff0d0', 54, 20, THREE.MathUtils.degToRad(28), 0.55, 1.7) + light.position.copy(lightPosition) + scene.add(light) + if (light.target) scene.add(light.target) + const backMaterial = new THREE.MeshPhysicalMaterial({ + map: textures[4], roughness: 0.42, metalness: 0, clearcoat: 0.2, + clearcoatRoughness: 0.24, normalMap: null, side: THREE.DoubleSide, + }) + const pack = new PackOpening({ + canvas: renderer.domElement, + textures: { + David: { artwork: textures[0], mask: textures[1] }, + Timothy: { artwork: textures[2], mask: textures[3] }, + }, + backMaterial, normalMap: textures[5], environment, lightPosition, onChange() {}, + }) + pack.resize(192, 264) + const revealed = [] + renderer.domElement.addEventListener('packreveal', (event) => revealed.push(event.detail)) + const originalNow = performance.now + try { + const programsBeforePreparation = renderer.info.programs.length + await pack.prepareGPU(renderer, scene) + if (pack.root.visible || pack.state !== 'sealed' || revealed.length) { + throw new Error('Preparation changed visibility or reveal state') + } + const preparedPrograms = new Set(renderer.info.programs.map((program) => program.id)) + if (!preparedPrograms.size) throw new Error('Preparation compiled no programs') + const texturesAfterPreparation = renderer.info.memory.textures + const unexpectedPrograms = new Map() + const visitedStates = new Set() + let renderedFrames = 0 + let maximumTextureCount = texturesAfterPreparation + const textureGrowth = [] + let now = originalNow.call(performance) + performance.now = () => now + function frame() { + pack.tick(now) + renderer.render(scene, pack.camera) + renderedFrames++ + visitedStates.add(pack.state) + if (renderer.info.memory.textures > maximumTextureCount) { + textureGrowth.push({ state: pack.state, count: renderer.info.memory.textures }) + } + maximumTextureCount = Math.max(maximumTextureCount, renderer.info.memory.textures) + for (const program of renderer.info.programs) { + if (!preparedPrograms.has(program.id) && !unexpectedPrograms.has(program.id)) { + unexpectedPrograms.set(program.id, { id: program.id, name: program.name, firstState: pack.state }) + } + } + const gl = renderer.getContext() + if (shaderErrors.length || gl.isContextLost() || gl.getError() !== gl.NO_ERROR) { + throw new Error(`Pack WebGL rendering failed: ${shaderErrors.join('\n')}`) + } + } + scene.add(pack.root) + pack.setActive(true) + frame() + const durations = { opening: 3200, lifting: 620, revealing: 820, advancing: 650 } + for (let action = 0; action < 10 && pack.state !== 'complete'; action++) { + pack.primary() + const duration = durations[pack.state] + if (!duration) throw new Error(`Unexpected animated state: ${pack.state}`) + const started = now + for (const progress of [0, 0.1, 0.25, 0.4, 0.5, 0.65, 0.8, 0.9, 1.001]) { + now = started + duration * progress + frame() + } + } + if (pack.state !== 'complete' || JSON.stringify(revealed.map(({ fixture, finish, substrate }) => + ({ fixture, finish, substrate }))) !== JSON.stringify(packContents)) { + throw new Error('First reveal cycle did not complete in authored order') + } + pack.restart() + frame() + results.push({ + lightType, passed: unexpectedPrograms.size === 0, programsBeforePreparation, + programsAfterPreparation: preparedPrograms.size, + programsAfterRevealAndRestart: renderer.info.programs.length, + unexpectedPrograms: [...unexpectedPrograms.values()], texturesAfterPreparation, + maximumTextureCount, textureGrowth, renderedFrames, visitedStates: [...visitedStates], revealed, + }) + } finally { + performance.now = originalNow + pack.dispose() + backMaterial.dispose() + renderer.dispose() + renderer.forceContextLoss() + } + } + return { passed: results.every((result) => result.passed), + durationMs: Math.round(performance.now() - startedAt), results } + } finally { + for (const texture of [...textures, environment]) texture.dispose() + } +} diff --git a/spikes/card-harness/scripts/shader-pixel-client.mjs b/spikes/card-harness/scripts/shader-pixel-client.mjs new file mode 100644 index 0000000..925f782 --- /dev/null +++ b/spikes/card-harness/scripts/shader-pixel-client.mjs @@ -0,0 +1,191 @@ +import * as THREE from 'three' +import { createCardMaterial, createStudioCubeTexture, applyMaterialControls } from '../src/cardMaterial.ts' +import { createCardGeometry } from '../src/cardGeometry.ts' +import { verifyPackPreparation } from './shader-pack-preparation.mjs' + +const width = 192 +const height = 264 +const substrates = ['Paper', 'Linen', 'Plastic', 'Metal', 'Wood'] +const finishes = ['Printed ink', 'Foil', 'Holographic'] +const conditions = [1, 0.999, 0.7, 0] +const angles = [0, 75] +const lights = [ + { name: 'Point', mode: 0, position: [2.2, 2.5, 4.4] }, + { name: 'Directional', mode: 1, position: [-2.8, 3.1, 4.8] }, + { name: 'Spot', mode: 2, position: [3.6, 1.9, 3.6] }, +] + +async function compare() { + const started = performance.now() + const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: true }) + renderer.setSize(width, height) + renderer.setPixelRatio(1) + renderer.setClearColor(0, 0) + renderer.outputColorSpace = THREE.SRGBColorSpace + renderer.toneMapping = THREE.ACESFilmicToneMapping + const compileErrors = [] + renderer.debug.onShaderError = (gl, program, vertex, fragment) => { + compileErrors.push([gl.getProgramInfoLog(program), gl.getShaderInfoLog(vertex), + gl.getShaderInfoLog(fragment)].join('\n')) + } + const gl = renderer.getContext() + const debugInfo = gl.getExtension('WEBGL_debug_renderer_info') + const gpu = { + version: gl.getParameter(gl.VERSION), + vendor: debugInfo ? gl.getParameter(debugInfo.UNMASKED_VENDOR_WEBGL) : gl.getParameter(gl.VENDOR), + renderer: debugInfo ? gl.getParameter(debugInfo.UNMASKED_RENDERER_WEBGL) : gl.getParameter(gl.RENDERER), + userAgent: navigator.userAgent, + } + const loader = new THREE.TextureLoader() + const [artwork, mask, normal, referenceSource] = await Promise.all([ + loader.loadAsync('/reference/david-front.png'), + loader.loadAsync('/reference/david-finish-mask.png'), + loader.loadAsync('/reference/linen-normal.png'), + fetch('/__shader_reference').then((response) => response.json()), + ]) + artwork.colorSpace = THREE.SRGBColorSpace + mask.colorSpace = THREE.NoColorSpace + normal.colorSpace = THREE.NoColorSpace + normal.wrapS = normal.wrapT = THREE.RepeatWrapping + const environment = createStudioCubeTexture() + const candidate = createCardMaterial(artwork, mask, normal, environment, new THREE.Vector3()) + const reference = candidate.clone() + reference.fragmentShader = referenceSource.fragmentShader + const baseline = candidate.clone() + baseline.fragmentShader = referenceSource.baselineShader + const edge = new THREE.MeshBasicMaterial({ color: '#9c8c69' }) + const card = createCardGeometry(candidate, candidate, edge) + const front = card.getObjectByName('CARD_FRONT') + // Isolate the changed face shader; unchanged edge/back pixels must not dilute coverage. + card.getObjectByName('CARD_EDGE').visible = false + card.getObjectByName('CARD_BACK').visible = false + const scene = new THREE.Scene() + scene.add(card) + const camera = new THREE.PerspectiveCamera(40, width / height, 0.1, 100) + camera.position.set(0, 0, 6.4) + camera.lookAt(0, 0, 0) + const target = new THREE.WebGLRenderTarget(width, height, { + type: THREE.UnsignedByteType, + format: THREE.RGBAFormat, + depthBuffer: true, + stencilBuffer: false, + }) + target.texture.colorSpace = THREE.SRGBColorSpace + const currentPixels = new Uint8Array(width * height * 4) + const originalPixels = new Uint8Array(currentPixels.length) + const baselinePixels = new Uint8Array(currentPixels.length) + function render(material, buffer) { + front.material = material + renderer.setRenderTarget(target) + renderer.render(scene, camera) + renderer.readRenderTargetPixels(target, 0, 0, width, height, buffer) + if (compileErrors.length) throw new Error(compileErrors.join('\n')) + if (gl.isContextLost()) throw new Error('WebGL context lost') + const error = gl.getError() + if (error !== gl.NO_ERROR) throw new Error(`WebGL error: ${error}`) + } + const failures = [] + const summary = { cases: 0, comparedPixels: 0, differingPixels: 0, differingChannels: 0, maxChannelDelta: 0 } + const byCondition = Object.fromEntries(conditions.map((value) => [value, + { cases: 0, differingPixels: 0, maxChannelDelta: 0 }])) + let minimumCoveredPixels = Infinity + let negativeControl + const baselineComparison = { unchangedCases: 0, differingPixels: 0, changedPixels: { Linen: 0, Metal: 0, Wood: 0 } } + try { + for (const substrate of substrates) for (const finish of finishes) { + for (const condition of conditions) for (const angle of angles) for (const light of lights) { + const controls = { + substrate, finish, condition, imperfectionSeed: 81251, finishStrength: 0.6, + roughness: 0.23, normalStrength: 0.14, environmentIntensity: 0.7, + } + for (const material of [candidate, reference, baseline]) { + applyMaterialControls(material, controls) + material.uniforms.lightPosition.value.set(...light.position) + material.uniforms.lightMode.value = light.mode + } + card.rotation.set(0, THREE.MathUtils.degToRad(angle), 0) + render(reference, originalPixels) + render(candidate, currentPixels) + let differingPixels = 0 + let maxChannelDelta = 0 + let coveredPixels = 0 + for (let i = 0; i < currentPixels.length; i += 4) { + if (originalPixels[i + 3]) coveredPixels++ + let different = false + for (let channel = 0; channel < 4; channel++) { + const delta = Math.abs(originalPixels[i + channel] - currentPixels[i + channel]) + if (delta) { + different = true + summary.differingChannels++ + maxChannelDelta = Math.max(maxChannelDelta, delta) + } + } + if (different) differingPixels++ + } + minimumCoveredPixels = Math.min(minimumCoveredPixels, coveredPixels) + if (coveredPixels < width * height * 0.05) throw new Error('Insufficient rendered card coverage') + summary.cases++ + summary.comparedPixels += width * height + summary.differingPixels += differingPixels + summary.maxChannelDelta = Math.max(summary.maxChannelDelta, maxChannelDelta) + byCondition[condition].cases++ + byCondition[condition].differingPixels += differingPixels + byCondition[condition].maxChannelDelta = Math.max(byCondition[condition].maxChannelDelta, maxChannelDelta) + if (differingPixels && failures.length < 30) { + failures.push({ substrate, finish, condition, angle, light: light.name, differingPixels, maxChannelDelta }) + } + render(baseline, baselinePixels) + let changed = 0 + for (let i = 0; i < currentPixels.length; i += 4) { + if (currentPixels.subarray(i, i + 4).some((value, channel) => value !== baselinePixels[i + channel])) changed++ + } + if (Object.hasOwn(baselineComparison.changedPixels, substrate)) { + baselineComparison.changedPixels[substrate] += changed + } else { + baselineComparison.unchangedCases++ + baselineComparison.differingPixels += changed + } + if (!negativeControl && condition === 1 && angle === 0) { + candidate.uniforms.condition.value = 0 + render(candidate, currentPixels) + let changed = 0 + for (let i = 0; i < currentPixels.length; i += 4) { + if (currentPixels.subarray(i, i + 4).some((value, channel) => value !== originalPixels[i + channel])) changed++ + } + if (changed < 100) throw new Error('Negative control failed: fully worn and mint unexpectedly match') + negativeControl = { mintVsFullyWornDifferingPixels: changed } + } + } + // Keep the browser event loop responsive, without unbounded animation frames. + await new Promise((resolve) => setTimeout(resolve, 0)) + } + return { + passed: summary.differingPixels === 0 && summary.cases === 360 + && baselineComparison.differingPixels === 0 + && Object.values(baselineComparison.changedPixels).every(value => value > 0), + gpu, resolution: [width, height], substrates, finishes, conditions, angles, lights, + seed: 81251, tolerance: 0, ...summary, byCondition, minimumCoveredPixels, negativeControl, + baselineComparison, failures, durationMs: Math.round(performance.now() - started), + } + } finally { + target.dispose() + candidate.dispose() + reference.dispose() + baseline.dispose() + edge.dispose() + card.traverse((object) => object.geometry?.dispose()) + for (const texture of [artwork, mask, normal, environment]) texture.dispose() + renderer.dispose() + } +} + +try { + const report = await compare() + report.packPreparation = await verifyPackPreparation() + report.passed &&= report.packPreparation.passed + await fetch('/__shader_pixels', { method: 'POST', body: JSON.stringify(report) }) +} catch (error) { + await fetch('/__shader_pixels', { + method: 'POST', body: JSON.stringify({ passed: false, error: String(error), stack: error.stack }), + }) +} diff --git a/spikes/card-harness/scripts/shader-pixel-regression.mjs b/spikes/card-harness/scripts/shader-pixel-regression.mjs new file mode 100644 index 0000000..6f706d2 --- /dev/null +++ b/spikes/card-harness/scripts/shader-pixel-regression.mjs @@ -0,0 +1,126 @@ +// Run: node scripts/shader-pixel-regression.mjs +// Optional: SHADER_BROWSER=/path/to/firefox SHADER_TIMEOUT_MS=180000 +// Chromium: SHADER_BROWSER=/path/to/chrome-headless-shell SHADER_BROWSER_KIND=chromium +// Hosts without user namespaces: SHADER_NO_SANDBOX=1 (local test content only). +// No automation dependency: the browser POSTs WebGL readback results to a loopback Vite server. +// Also checks that prepared packs create no new programs through reveal and restart. +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { readFile, mkdir, writeFile, rm } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createServer } from 'vite' +import { originalFragment, baselineFragment, referenceCommit, referenceHash } from './shader-wear-reference.mjs' + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const profile = resolve(root, 'scripts', `.shader-browser-${process.pid}`) +const timeoutMs = Number(process.env.SHADER_TIMEOUT_MS ?? 180000) +if (!Number.isFinite(timeoutMs) || timeoutMs < 1000 || timeoutMs > 600000) { + throw new Error('SHADER_TIMEOUT_MS must be between 1000 and 600000') +} +const fragmentShader = originalFragment(await readFile(resolve(root, 'src/cardMaterial.ts'), 'utf8')) +const baselineShader = baselineFragment(root) +let resolveResult +let rejectResult +const result = new Promise((resolve, reject) => { + resolveResult = resolve + rejectResult = reject +}) +// Attach a handler immediately; startup errors can precede awaiting the result. +result.catch(() => {}) +let server +let browser +let timer +try { + await mkdir(profile) + await writeFile(resolve(profile, 'user.js'), [ + 'user_pref("webgl.force-enabled", true);', + 'user_pref("webgl.disabled", false);', + 'user_pref("browser.shell.checkDefaultBrowser", false);', + 'user_pref("browser.startup.homepage_override.mstone", "ignore");', + 'user_pref("datareporting.policy.dataSubmissionEnabled", false);', + ].join('\n')) + server = await createServer({ + root, + configFile: false, + cacheDir: resolve(profile, 'vite'), + logLevel: 'error', + server: { host: '127.0.0.1', port: 0, watch: null }, + plugins: [{ + name: 'shader-pixel-regression', + configureServer(vite) { + vite.middlewares.use((req, res, next) => { + if (req.url === '/__shader_reference') { + res.setHeader('Content-Type', 'application/json') + res.end(JSON.stringify({ fragmentShader, baselineShader })) + } else if (req.url === '/__shader_pixels' && req.method === 'POST') { + let body = '' + req.on('data', (chunk) => { + body += chunk + if (body.length > 2_000_000) req.destroy() + }) + req.on('end', () => { + try { + resolveResult(JSON.parse(body)) + res.end('ok') + } catch (error) { + rejectResult(error) + res.statusCode = 400 + res.end('Invalid JSON') + } + }) + } else if (req.url === '/__shader_test') { + res.setHeader('Content-Type', 'text/html') + res.end('Shader pixel regression') + } else { + next() + } + }) + }, + }], + }) + await server.listen() + const address = server.httpServer.address() + const url = `http://127.0.0.1:${address.port}/__shader_test` + const probe = await fetch(url) + if (!probe.ok) throw new Error(`Local test server failed: ${probe.status}`) + console.log(`Shader reference verified against ${referenceCommit} (${referenceHash})`) + console.log(`Serving ${url}; browser timeout ${timeoutMs}ms`) + timer = setTimeout(() => rejectResult(new Error('Browser pixel regression timed out')), timeoutMs) + const browserArgs = process.env.SHADER_BROWSER_KIND === 'chromium' ? [ + '--headless', `--user-data-dir=${profile}`, '--no-first-run', + '--disable-background-networking', '--disable-dev-shm-usage', + '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', url, + ] : ['--headless', '--no-remote', '--profile', profile, url] + if (process.env.SHADER_BROWSER_KIND === 'chromium' && process.env.SHADER_NO_SANDBOX === '1') { + browserArgs.unshift('--no-sandbox') + } + browser = spawn(process.env.SHADER_BROWSER ?? '/usr/bin/firefox', browserArgs, { + env: { ...process.env, TMPDIR: profile, MOZ_HEADLESS: '1' }, + stdio: ['ignore', 'ignore', 'pipe'], + }) + let browserLog = '' + browser.stderr.on('data', (chunk) => { browserLog = (browserLog + chunk).slice(-12000) }) + browser.once('error', rejectResult) + browser.once('exit', (code) => rejectResult(new Error(`Browser exited with ${code}\n${browserLog}`))) + let report + try { + report = await result + } catch (error) { + console.error(browserLog) + throw error + } + console.log(JSON.stringify({ referenceCommit, referenceHash, ...report }, null, 2)) + if (!report.passed) process.exitCode = 1 +} finally { + clearTimeout(timer) + if (browser?.pid && browser.exitCode === null && browser.signalCode === null) { + const exited = once(browser, 'exit') + browser.kill('SIGTERM') + const forceKill = setTimeout(() => browser.kill('SIGKILL'), 3000) + await exited + clearTimeout(forceKill) + } + await server?.close() + await rm(profile, { recursive: true, force: true }) +} diff --git a/spikes/card-harness/scripts/shader-wear-reference.mjs b/spikes/card-harness/scripts/shader-wear-reference.mjs new file mode 100644 index 0000000..a474d9b --- /dev/null +++ b/spikes/card-harness/scripts/shader-wear-reference.mjs @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' + +// SHA-256 of the whitespace-normalized fragment shader at this pre-fast-path commit. +export const referenceCommit = '2cfa0ca690134aa4988f01f0170e9e202b608c1c' +export const referenceHash = '5490249511bf2511ac78b6ef3b061fc8ef12ed42b255fe567954c16d5559f9b4' + +export function baselineFragment(root) { + const source = execFileSync('git', [ + 'show', `${referenceCommit}:spikes/card-harness/src/cardMaterial.ts`, + ], { cwd: root, encoding: 'utf8' }) + const fragment = source.match(/const fragmentShader = `([\s\S]*?)`/)?.[1] + assert.ok(fragment, 'Cannot find original card fragment shader') + const hash = createHash('sha256').update(fragment.replace(/\s+/g, '')).digest('hex') + assert.equal(hash, referenceHash, 'Original shader reference must retain its recorded hash') + return fragment +} + +export function originalFragment(source) { + const fragment = source.match(/const fragmentShader = `([\s\S]*?)`/)?.[1] + assert.ok(fragment, 'Cannot find card fragment shader') + const gate = /float edgeWear = 0\.0;\s*float scratchWear = 0\.0;\s*float scuffWear = 0\.0;\s*if \(condition < 1\.0\) \{([\s\S]*?)\}\s*(?=float wearDamage)/ + assert.ok(gate.test(fragment), 'Expected the mint-only wear fast path') + const original = fragment.replace(gate, (_, body) => body.replace( + /^(\s*)(edgeWear|scratchWear|scuffWear) =/gm, '$1float $2 =', + )) + return original +} diff --git a/spikes/card-harness/src/cardMaterial.ts b/spikes/card-harness/src/cardMaterial.ts index 75c16c2..086dd7e 100644 --- a/spikes/card-harness/src/cardMaterial.ts +++ b/spikes/card-harness/src/cardMaterial.ts @@ -1,4 +1,5 @@ import * as THREE from 'three' +import { cardSceneDimensions } from './cardGeometry' export type FinishName = 'Printed ink' | 'Foil' | 'Holographic' export type SubstrateName = 'Paper' | 'Linen' | 'Plastic' | 'Metal' | 'Wood' @@ -98,6 +99,7 @@ const fragmentShader = ` uniform float normalStrength; uniform float condition; uniform float imperfectionSeed; + uniform vec2 cardFaceSize; uniform int finishMode; uniform int substrateMode; uniform int lightMode; @@ -132,6 +134,25 @@ const fragmentShader = ` return sin(phase) * visibility; } + // Height and analytic UV slopes keep thread ridges lit even at oblique angles. + vec3 linenRelief(vec2 uv, vec4 visibility) { + vec2 frequency = vec2(52.0, 73.0) * 6.2831853; + vec2 phase = uv * frequency; + vec2 thread = sin(phase) * visibility.xy; + vec2 crossing = sin(phase * 0.5) * visibility.zw; + float over = 0.5 + 0.5 * crossing.x * crossing.y; + vec2 threadSlope = cos(phase) * frequency * visibility.xy; + vec2 crossingSlope = cos(phase * 0.5) * frequency * 0.5 * visibility.zw; + vec2 overSlope = 0.5 * crossingSlope * crossing.yx; + vec2 slope = vec2((1.0 - over) * threadSlope.x, over * threadSlope.y) + + (thread.y - thread.x) * overSlope; + return vec3(0.5 + 0.5 * mix(thread.x, thread.y, over), slope * 0.5); + } + + float metalDepth(vec3 ink) { + return smoothstep(0.08, 0.92, 1.0 - dot(ink, vec3(0.2126, 0.7152, 0.0722))); + } + float woodGrain(vec2 uv) { float warp = fiberNoise(uv * vec2(4.0, 2.5)); float phase = uv.x * 170.0 + warp * 16.0 + sin(uv.y * 9.0) * 2.5; @@ -208,28 +229,55 @@ const fragmentShader = ` vec2 artworkUv = vUv; vec4 artworkSample = texture2D(artwork, artworkUv); float mask = texture2D(finishMask, artworkUv).r; - float wearAmount = pow(clamp(1.0 - condition, 0.0, 1.0), 1.15); - float edgeDistance = min( - min(artworkUv.x, 1.0 - artworkUv.x), - min(artworkUv.y, 1.0 - artworkUv.y) - ); - float edgeNoise = fiberNoise(artworkUv * vec2(43.0, 61.0) + imperfectionSeed * 0.0007); - float edgeWidth = mix(0.0015, 0.032, wearAmount) * mix(0.65, 1.25, edgeNoise); - float edgeWear = (1.0 - smoothstep(edgeWidth * 0.45, edgeWidth, edgeDistance)) * wearAmount; - float scratchWear = max( - scratchLine(artworkUv, 1.0), - max(scratchLine(artworkUv, 2.0), scratchLine(artworkUv, 3.0)) - ) * smoothstep(0.04, 0.48, wearAmount); - float scuffWear = max( - scuffMark(artworkUv, 1.0), - scuffMark(artworkUv, 2.0) - ) * smoothstep(0.18, 0.82, wearAmount); + float metalRelief = 0.010 * min(normalStrength / 0.14, 1.5); + float metalBoundary = 1.0; + if (substrateMode == 3) { + vec2 edgeDistance = min(vUv, 1.0 - vUv); + metalBoundary = smoothstep(0.0, 0.025, min(edgeDistance.x, edgeDistance.y)); + vec3 view = normalize(cameraPosition - vWorldPosition); + vec2 alongSurface = vec2(dot(view, normalize(vWorldTangent)), + dot(view, normalize(vWorldBitangent))); + vec2 ray = alongSurface / max(abs(dot(view, normalize(vWorldNormal))), 0.35) + * metalRelief * metalBoundary / cardFaceSize; + float exposure = finishMode == 1 ? 1.0 - mask * 0.25 : 1.0; + float depth = metalDepth(artworkSample.rgb) * exposure; + // A bounded predictor/corrector adds recess parallax without ray marching. + vec2 predictedUv = clamp(vUv - ray * depth, vec2(0.0), vec2(1.0)); + float predictedDepth = metalDepth(texture2D(artwork, predictedUv).rgb) * exposure; + artworkUv = clamp(vUv - ray * (depth + predictedDepth) * 0.5, vec2(0.0), vec2(1.0)); + artworkSample = texture2D(artwork, artworkUv); + mask = texture2D(finishMask, artworkUv).r; + } + float edgeWear = 0.0; + float scratchWear = 0.0; + float scuffWear = 0.0; + if (condition < 1.0) { + float wearAmount = pow(clamp(1.0 - condition, 0.0, 1.0), 1.15); + float edgeDistance = min( + min(artworkUv.x, 1.0 - artworkUv.x), + min(artworkUv.y, 1.0 - artworkUv.y) + ); + float edgeNoise = fiberNoise(artworkUv * vec2(43.0, 61.0) + imperfectionSeed * 0.0007); + float edgeWidth = mix(0.0015, 0.032, wearAmount) * mix(0.65, 1.25, edgeNoise); + edgeWear = (1.0 - smoothstep(edgeWidth * 0.45, edgeWidth, edgeDistance)) * wearAmount; + scratchWear = max( + scratchLine(artworkUv, 1.0), + max(scratchLine(artworkUv, 2.0), scratchLine(artworkUv, 3.0)) + ) * smoothstep(0.04, 0.48, wearAmount); + scuffWear = max( + scuffMark(artworkUv, 1.0), + scuffMark(artworkUv, 2.0) + ) * smoothstep(0.18, 0.82, wearAmount); + } float wearDamage = max(edgeWear, max(scratchWear * 0.8, scuffWear * 0.55)); mask *= 1.0 - wearDamage * 0.82; vec3 normalSample = texture2D(normalMap, artworkUv * 2.2).xyz * 2.0 - 1.0; float detail = normalStrength / 0.14; float surfaceHeight = 0.0; float surfaceShade = 1.0; + vec2 linenSlope = vec2(0.0); + float linenRidge = 0.0; + float woodSurfaceGrain = 0.5; float metalEtchDepth = 0.0; float metalEtchExposure = 1.0; if (substrateMode == 0) { @@ -240,30 +288,45 @@ const fragmentShader = ` surfaceShade = 1.0 + fibers * 0.045 * min(detail, 1.5); } if (substrateMode == 1) { - vec2 thread = artworkUv * vec2(52.0, 73.0) * 6.2831853; - float warp = filteredWave(thread.x); - float weft = filteredWave(thread.y); - float overUnder = filteredWave(thread.x * 0.5) * filteredWave(thread.y * 0.5); - float weave = mix(warp, weft, 0.5 + 0.5 * overUnder); - surfaceHeight = weave * 0.005 * detail; - surfaceShade = 1.0 - (1.0 - weave) * 0.055 * min(detail, 1.5); + vec2 phase = artworkUv * vec2(52.0, 73.0) * 6.2831853; + vec2 footprint = fwidth(phase); + vec4 visibility = 1.0 - smoothstep(vec4(0.7), vec4(3.0), + vec4(footprint, footprint * 0.5)); + float relief = 0.010 * min(detail, 1.5); + vec3 weave = linenRelief(artworkUv, visibility); + vec3 view = normalize(cameraPosition - vWorldPosition); + vec2 alongSurface = vec2(dot(view, normalize(vWorldTangent)), + dot(view, normalize(vWorldBitangent))); + // Offset only the weave, not the illustration, mask or printed lettering. + vec2 parallax = alongSurface / max(abs(dot(view, normalize(vWorldNormal))), 0.3) + * (weave.x - 0.5) * relief / cardFaceSize; + weave = linenRelief(artworkUv + parallax, visibility); + linenSlope = weave.yz * relief / cardFaceSize; + linenRidge = smoothstep(0.45, 0.9, weave.x) * min(visibility.x, visibility.y) * min(detail, 1.5); + surfaceShade = 1.0 - (1.0 - weave.x) * 0.11 * min(detail, 1.5); } if (substrateMode == 3) { - float artworkLuminance = dot(artworkSample.rgb, vec3(0.2126, 0.7152, 0.0722)); - metalEtchDepth = smoothstep(0.08, 0.92, 1.0 - artworkLuminance); + metalEtchDepth = metalDepth(artworkSample.rgb); if (finishMode == 1) { metalEtchExposure = 1.0 - mask * 0.25; } - surfaceHeight = -metalEtchDepth * metalEtchExposure * 0.0065 * min(detail, 1.5); + surfaceHeight = -metalEtchDepth * metalEtchExposure * metalRelief; if (finishMode == 1) { surfaceHeight += mask * 0.0005 * min(detail, 1.5); } surfaceShade = 1.0 - metalEtchDepth * metalEtchExposure * 0.035; } if (substrateMode == 4) { + float relief = 0.012 * min(detail, 1.5); + vec3 view = normalize(cameraPosition - vWorldPosition); + vec2 alongSurface = vec2(dot(view, normalize(vWorldTangent)), + dot(view, normalize(vWorldBitangent))); float grain = woodGrain(artworkUv); - surfaceHeight = grain * 0.007 * detail; - surfaceShade = 0.88 + grain * 0.12; + vec2 parallax = alongSurface / max(abs(dot(view, normalize(vWorldNormal))), 0.35) + * (grain - 0.5) * relief / cardFaceSize; + woodSurfaceGrain = woodGrain(artworkUv + parallax); + surfaceHeight = woodSurfaceGrain * relief; + surfaceShade = 0.88 + woodSurfaceGrain * 0.12; } surfaceHeight -= scratchWear * 0.0018 + scuffWear * 0.0007; float substrateNormalScale = substrateMode == 3 ? 0.06 : 0.12; @@ -273,6 +336,10 @@ const fragmentShader = ` vWorldBitangent * normalSample.y * normalStrength * substrateNormalScale ); normal = reliefNormal(normal, surfaceHeight); + if (substrateMode == 1) { + normal = normalize(normal - normalize(vWorldTangent) * linenSlope.x + - normalize(vWorldBitangent) * linenSlope.y); + } vec3 viewDirection = normalize(cameraPosition - vWorldPosition); vec3 lightVector = lightPosition - vWorldPosition; @@ -316,6 +383,12 @@ const fragmentShader = ` float reflectionStrength = substrateMode == 1 ? 0.025 : (substrateMode == 4 ? 0.09 : 0.045); printedInk += lightColor * substrateSpecular * reflectionStrength; } + if (substrateMode == 1) { + float threadHighlight = pow(max(dot(normal, halfVector), 0.0), 24.0); + float grazing = 1.0 - max(dot(normalize(vWorldNormal), viewDirection), 0.0); + printedInk += lightColor * threadHighlight * diffuse * lightIntensity * attenuation + * linenRidge * (0.025 + 0.055 * grazing); + } if (substrateMode == 2) { float reflectedFraction = min(0.32, dielectricFresnel * environmentIntensity); printedInk = printedInk * (1.0 - reflectedFraction) + environment * reflectedFraction; @@ -324,6 +397,13 @@ const fragmentShader = ` if (substrateMode == 3) { float visibleEtch = metalEtchDepth * metalEtchExposure; float etchEdge = smoothstep(0.002, 0.045, fwidth(visibleEtch)); + vec2 alongLight = vec2(dot(lightDirection, normalize(vWorldTangent)), + dot(lightDirection, normalize(vWorldBitangent))); + vec2 ridgeUv = clamp(artworkUv + alongLight * metalRelief * metalBoundary / cardFaceSize, + vec2(0.0), vec2(1.0)); + float ridgeDepth = metalDepth(texture2D(artwork, ridgeUv).rgb) * metalEtchExposure; + float recessShadow = smoothstep(0.02, 0.25, visibleEtch - ridgeDepth) + * min(detail, 1.5) * metalBoundary; vec3 metalUnderprint = mix( artworkSample.rgb * vec3(0.72, 0.76, 0.82), environment, @@ -334,10 +414,10 @@ const fragmentShader = ` printedInk = mix(printedInk, metalUnderprint, 0.42); printedInk *= 1.0 - visibleEtch * 0.06; printedInk += lightColor * etchEdge * 0.025; + printedInk *= 1.0 - recessShadow * 0.22; } if (substrateMode == 4) { - float grain = woodGrain(artworkUv); - vec3 woodTint = mix(vec3(0.72, 0.40, 0.18), vec3(1.0, 0.86, 0.65), grain); + vec3 woodTint = mix(vec3(0.72, 0.40, 0.18), vec3(1.0, 0.86, 0.65), woodSurfaceGrain); printedInk *= mix(vec3(1.0), woodTint, 0.25); } if (finishMode == 0) { @@ -455,6 +535,7 @@ export function createCardMaterial( normalStrength: { value: 0.14 }, condition: { value: 1.0 }, imperfectionSeed: { value: 81251 }, + cardFaceSize: { value: new THREE.Vector2(cardSceneDimensions.width, cardSceneDimensions.height) }, finishMode: { value: 2 }, substrateMode: { value: 0 }, lightMode: { value: 0 }, diff --git a/spikes/card-harness/src/foilWrapper.ts b/spikes/card-harness/src/foilWrapper.ts index c39772f..4f5afa4 100644 --- a/spikes/card-harness/src/foilWrapper.ts +++ b/spikes/card-harness/src/foilWrapper.ts @@ -173,5 +173,12 @@ export function createFoilWrapper() { strip.visible = detach < 1 } deform(0, 0, 0, new THREE.Vector3()) - return { root, deform } + function dispose() { + for (const { mesh } of sheets) mesh.geometry.dispose() + stripGeometry.dispose() + frontTexture.dispose() + backMaterial.map!.dispose() + for (const material of [frontMaterial, backMaterial, stripMaterial]) material.dispose() + } + return { root, deform, dispose } } diff --git a/spikes/card-harness/src/main.ts b/spikes/card-harness/src/main.ts index 9d2decf..374eab4 100644 --- a/spikes/card-harness/src/main.ts +++ b/spikes/card-harness/src/main.ts @@ -720,6 +720,8 @@ async function preparePack() { packLoading = true packError = undefined updatePackUI() + let preparedPack: PackOpening | undefined + let textures: THREE.Texture[] = [] try { // Separate texture ownership keeps local artwork, experiments and fixture disposal independent. const paths = [fixtureAssets.David.artwork, fixtureAssets.David.mask, @@ -732,12 +734,12 @@ async function preparePack() { } throw failure.reason } - const textures = results.map((result, index) => { + textures = results.map((result, index) => { if (result.status !== 'fulfilled') throw new Error(`Missing pack asset: ${paths[index]}`) configureFrontTexture(result.value, index % 2 ? THREE.NoColorSpace : THREE.SRGBColorSpace) return result.value }) - pack = new PackOpening({ + preparedPack = new PackOpening({ canvas, textures: { David: { artwork: textures[0], mask: textures[1] }, @@ -749,12 +751,24 @@ async function preparePack() { lightPosition, onChange: () => { packUIDirty = true }, }) - scene.add(pack.root) - pack.syncLighting(frontMaterial) - pack.setActive(mode === 'Pack') - pack.resize(canvas.clientWidth, canvas.clientHeight) + preparedPack.resize(canvas.clientWidth, canvas.clientHeight) + let preparedEnvironment: THREE.Texture | null + let preparedLightType: LightType + do { + preparedEnvironment = scene.environment + preparedLightType = controls.lightType + preparedPack.syncLighting(frontMaterial) + await preparedPack.prepareGPU(renderer, scene) + } while (scene.environment !== preparedEnvironment || controls.lightType !== preparedLightType) + preparedPack.syncLighting(frontMaterial) + preparedPack.resize(canvas.clientWidth, canvas.clientHeight) + preparedPack.setActive(mode === 'Pack') + scene.add(preparedPack.root) + pack = preparedPack } catch (error) { - packError = `Pack assets unavailable: ${error instanceof Error ? error.message : String(error)}` + preparedPack?.dispose() + for (const texture of textures) texture.dispose() + packError = `Pack unavailable: ${error instanceof Error ? error.message : String(error)}` console.error('Could not prepare pack prototype', error) } finally { packLoading = false diff --git a/spikes/card-harness/src/packOpening.ts b/spikes/card-harness/src/packOpening.ts index ecafddf..f8208cf 100644 --- a/spikes/card-harness/src/packOpening.ts +++ b/spikes/card-harness/src/packOpening.ts @@ -186,6 +186,54 @@ export class PackOpening { } } + async prepareGPU(renderer: THREE.WebGLRenderer, scene: THREE.Scene) { + if (this.active) throw new Error('Prepare pack GPU resources before activating the pack') + const textures = new Set() + if (scene.environment) textures.add(scene.environment) + this.root.traverse((object) => { + if (!(object instanceof THREE.Mesh)) return + const materials = Array.isArray(object.material) ? object.material : [object.material] + for (const material of materials) { + for (const value of Object.values(material)) { + if (value instanceof THREE.Texture) textures.add(value) + } + if (material instanceof THREE.ShaderMaterial) { + for (const uniform of Object.values(material.uniforms)) { + if (uniform.value instanceof THREE.Texture) textures.add(uniform.value) + } + } + } + }) + for (const texture of textures) renderer.initTexture(texture) + // compileAsync visits hidden fronts without drawing them or changing reveal state. + await renderer.compileAsync(this.root, this.camera, scene) + try { + this.cards.forEach((card, index) => { + applyEdgeMaterialControls(card.edge, { ...packContents[index], condition: 1 }) + }) + await renderer.compileAsync(this.root, this.camera, scene) + } finally { + for (const card of this.cards) applyEdgeMaterialControls(card.edge, { substrate: 'Paper', condition: 1 }) + } + } + + dispose() { + this.clearPointers() + this.active = false + this.root.visible = false + this.root.removeFromParent() + this.wrapper.dispose() + const geometries = new Set() + for (const card of this.cards) { + card.object.traverse((object) => { + if (object instanceof THREE.Mesh) geometries.add(object.geometry) + }) + card.material.dispose() + card.edge.dispose() + } + for (const geometry of geometries) geometry.dispose() + } + setActive(active: boolean) { if (!active) this.pause(performance.now()) this.clearPointers()