Compare commits
18 commits
main
...
feature/wa
| Author | SHA1 | Date | |
|---|---|---|---|
| ab46d90d6a | |||
| e72a0ec72f | |||
| c4290980ed | |||
| 80db7ad3bf | |||
| 481fbce6ce | |||
| 88797bc165 | |||
| 079bcc8d1d | |||
| f4417f7fb0 | |||
| e8d768961b | |||
| dad185150c | |||
| f96569b2bf | |||
| 58fabad3c2 | |||
| ddb89dc2fa | |||
| 14298fafa9 | |||
| 2dc5505147 | |||
| 0651a2871c | |||
| a00d57bcbb | |||
| 276cc1b5ea |
26 changed files with 2735 additions and 76 deletions
|
|
@ -24,24 +24,6 @@ document.addEventListener("turbo:frame-missing", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
});
|
});
|
||||||
|
|
||||||
class SpeciesColorPicker extends HTMLElement {
|
|
||||||
#internals;
|
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.#internals = this.attachInternals();
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
// Listen for changes to auto-submit the form, then tell CSS about it!
|
|
||||||
this.addEventListener("change", this.#handleChange);
|
|
||||||
this.#internals.states.add("auto-loading");
|
|
||||||
}
|
|
||||||
|
|
||||||
#handleChange(e) {
|
|
||||||
this.querySelector("form").requestSubmit();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SpeciesFacePicker extends HTMLElement {
|
class SpeciesFacePicker extends HTMLElement {
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
|
|
@ -109,7 +91,6 @@ class MeasuredContainer extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define("species-color-picker", SpeciesColorPicker);
|
|
||||||
customElements.define("species-face-picker", SpeciesFacePicker);
|
customElements.define("species-face-picker", SpeciesFacePicker);
|
||||||
customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
|
customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
|
||||||
customElements.define("measured-container", MeasuredContainer);
|
customElements.define("measured-container", MeasuredContainer);
|
||||||
|
|
|
||||||
|
|
@ -126,7 +126,7 @@ class OutfitLayer extends HTMLElement {
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`<outfit-layer> got unexpected status: ` +
|
`<outfit-layer> got unexpected status: ` +
|
||||||
JSON.stringify(data.status),
|
JSON.stringify(data.status),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -196,7 +196,7 @@ function morphWithOutfitLayers(currentElement, newElement) {
|
||||||
if (
|
if (
|
||||||
newNode.tagName === "OUTFIT-LAYER" &&
|
newNode.tagName === "OUTFIT-LAYER" &&
|
||||||
newNode.getAttribute("data-asset-id") !==
|
newNode.getAttribute("data-asset-id") !==
|
||||||
currentNode.getAttribute("data-asset-id")
|
currentNode.getAttribute("data-asset-id")
|
||||||
) {
|
) {
|
||||||
currentNode.replaceWith(newNode);
|
currentNode.replaceWith(newNode);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -205,10 +205,19 @@ function morphWithOutfitLayers(currentElement, newElement) {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
addEventListener("turbo:before-frame-render", (event) => {
|
|
||||||
|
function onTurboRender(event) {
|
||||||
// Rather than enforce Idiomorph must be loaded, let's just be resilient
|
// Rather than enforce Idiomorph must be loaded, let's just be resilient
|
||||||
// and only bother if we have it. (Replacing content is not *that* bad!)
|
// and only bother if we have it. (Replacing content is not *that* bad!)
|
||||||
if (typeof Idiomorph !== "undefined") {
|
if (typeof Idiomorph !== "undefined") {
|
||||||
event.detail.render = (a, b) => morphWithOutfitLayers(a, b);
|
event.detail.render = (a, b) => morphWithOutfitLayers(a, b);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// On most pages, we only apply this to Turbo frames, to be conservative. (Morphing the whole page is hard!)
|
||||||
|
addEventListener("turbo:before-frame-render", onTurboRender);
|
||||||
|
|
||||||
|
// But on pages that opt into it (namely the wardrobe), we do it for the full page too.
|
||||||
|
if (document.querySelector('meta[name=outfit-viewer-morph-mode][value=full-page]') !== null) {
|
||||||
|
addEventListener("turbo:before-render", onTurboRender);
|
||||||
|
}
|
||||||
|
|
|
||||||
6
app/assets/javascripts/outfits/new_v2.js
Normal file
6
app/assets/javascripts/outfits/new_v2.js
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Wardrobe v2 - Simple Rails+Turbo outfit editor
|
||||||
|
//
|
||||||
|
// This page uses Turbo Frames for instant updates when changing species/color.
|
||||||
|
// The outfit_viewer Web Component handles the pet rendering.
|
||||||
|
|
||||||
|
console.log("Wardrobe v2 loaded!");
|
||||||
28
app/assets/javascripts/species-color-picker.js
Normal file
28
app/assets/javascripts/species-color-picker.js
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* SpeciesColorPicker web component
|
||||||
|
*
|
||||||
|
* Progressive enhancement for species/color picker forms:
|
||||||
|
* - Auto-submits the form when species or color changes (if JS is enabled)
|
||||||
|
* - Shows a submit button as fallback (if JS is disabled or slow to load)
|
||||||
|
* - Uses Custom Element internals API to communicate state to CSS
|
||||||
|
*/
|
||||||
|
class SpeciesColorPicker extends HTMLElement {
|
||||||
|
#internals;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super();
|
||||||
|
this.#internals = this.attachInternals();
|
||||||
|
}
|
||||||
|
|
||||||
|
connectedCallback() {
|
||||||
|
// Listen for changes to auto-submit the form, then tell CSS about it!
|
||||||
|
this.addEventListener("change", this.#handleChange);
|
||||||
|
this.#internals.states.add("auto-loading");
|
||||||
|
}
|
||||||
|
|
||||||
|
#handleChange(e) {
|
||||||
|
this.querySelector("form").requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("species-color-picker", SpeciesColorPicker);
|
||||||
32
app/assets/stylesheets/application/item-badges.css
Normal file
32
app/assets/stylesheets/application/item-badges.css
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
* Shared item badge styles for NC/NP/PB badges
|
||||||
|
* Used across item pages, wardrobe, search results, etc.
|
||||||
|
*
|
||||||
|
* These colors are from DTI 2020, based on Chakra UI's color palette.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.item-badge {
|
||||||
|
padding: 0.25em 0.5em;
|
||||||
|
border-radius: 0.25em;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: .75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
background: #E2E8F0;
|
||||||
|
color: #1A202C;
|
||||||
|
|
||||||
|
&[data-item-kind="nc"] {
|
||||||
|
background: #E9D8FD;
|
||||||
|
color: #44337A;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-item-kind="pb"] {
|
||||||
|
background: #FEEBC8;
|
||||||
|
color: #7B341E;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,6 +46,7 @@ outfit-viewer
|
||||||
img, iframe
|
img, iframe
|
||||||
width: 100%
|
width: 100%
|
||||||
height: 100%
|
height: 100%
|
||||||
|
border: 0
|
||||||
|
|
||||||
.loading-indicator
|
.loading-indicator
|
||||||
position: absolute
|
position: absolute
|
||||||
|
|
|
||||||
545
app/assets/stylesheets/outfits/new_v2.css
Normal file
545
app/assets/stylesheets/outfits/new_v2.css
Normal file
|
|
@ -0,0 +1,545 @@
|
||||||
|
@import "../application/item-badges.css";
|
||||||
|
|
||||||
|
body.wardrobe-v2 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wardrobe-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
background: #000;
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-preview-section {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #000;
|
||||||
|
position: relative;
|
||||||
|
min-height: 400px;
|
||||||
|
container-type: size;
|
||||||
|
|
||||||
|
/* The outfit viewer is a square filling the space, to at most 600px. */
|
||||||
|
outfit-viewer {
|
||||||
|
width: min(100cqw, 100cqh, 600px);
|
||||||
|
height: min(100cqw, 100cqh, 600px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-preview-message {
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Species/color picker floats over the preview at the bottom */
|
||||||
|
species-color-picker {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
pointer-events: none;
|
||||||
|
/* Allow clicks through when hidden */
|
||||||
|
|
||||||
|
/* Start hidden, reveal on hover or focus */
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
|
||||||
|
form {
|
||||||
|
pointer-events: auto;
|
||||||
|
/* Re-enable clicks on the form itself */
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||||
|
background-position: right 0.5rem center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 1.5em 1.5em;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(255, 255, 255, 0.8);
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
option {
|
||||||
|
background: #2D3748;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submit button: progressive enhancement pattern */
|
||||||
|
/* If JS is disabled, the button is always visible */
|
||||||
|
/* If JS is enabled but slow to load, fade in after 0.75s */
|
||||||
|
/* Once the web component loads, hide the button completely */
|
||||||
|
input[type="submit"] {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: rgba(255, 255, 255, 0.8);
|
||||||
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* If JS is enabled, hide the submit button initially with a delay */
|
||||||
|
@media (scripting: enabled) {
|
||||||
|
input[type="submit"] {
|
||||||
|
position: absolute;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
opacity: 0;
|
||||||
|
animation: fade-in 0.25s forwards;
|
||||||
|
animation-delay: 0.75s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Once auto-loading is ready, hide the submit button completely */
|
||||||
|
&:state(auto-loading) {
|
||||||
|
input[type="submit"] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show picker on hover (real hover only, not simulated touch hover) */
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover species-color-picker {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show picker when it has focus */
|
||||||
|
&:has(species-color-picker:focus-within) species-color-picker {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-controls-section {
|
||||||
|
width: 400px;
|
||||||
|
background: #fff;
|
||||||
|
padding: 2rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 40vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin-top: 0;
|
||||||
|
font-size: 1.75rem;
|
||||||
|
color: #448844;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: #448844;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-selection {
|
||||||
|
padding: 1rem;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 1rem 0;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.worn-items {
|
||||||
|
margin-top: 2rem;
|
||||||
|
|
||||||
|
.items-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f9f9f9;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #333;
|
||||||
|
transition: background 0.2s, box-shadow 0.2s;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .item-remove-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-thumbnail {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
/* Allow text to truncate */
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2D3748;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-remove-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s, background 0.2s, transform 0.1s;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 1);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
opacity: 1;
|
||||||
|
outline: 2px solid #448844;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-search-form {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #448844;
|
||||||
|
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="submit"] {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #448844;
|
||||||
|
color: white;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #357535;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results {
|
||||||
|
.search-results-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: white;
|
||||||
|
color: #448844;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: background 0.2s, border-color 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-color: #448844;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #448844;
|
||||||
|
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 1rem 0;
|
||||||
|
|
||||||
|
.item-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f9f9f9;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #333;
|
||||||
|
transition: background 0.2s, box-shadow 0.2s;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .item-add-button {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-thumbnail {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2D3748;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-add-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
background: rgba(68, 136, 68, 0.9);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s, background 0.2s, transform 0.1s;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(68, 136, 68, 1);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
opacity: 1;
|
||||||
|
outline: 2px solid #448844;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
|
||||||
|
a,
|
||||||
|
span,
|
||||||
|
em {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #448844;
|
||||||
|
background: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f9f9f9;
|
||||||
|
border-color: #448844;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.current,
|
||||||
|
em {
|
||||||
|
background: #448844;
|
||||||
|
color: white;
|
||||||
|
border-color: #448844;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.disabled {
|
||||||
|
color: #ccc;
|
||||||
|
cursor: not-allowed;
|
||||||
|
border-color: #eee;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: white;
|
||||||
|
border-color: #eee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
@import "../partials/clean/constants"
|
@import "../partials/clean/constants"
|
||||||
@import "../partials/clean/mixins"
|
@import "../partials/clean/mixins"
|
||||||
|
@import "../application/item-badges"
|
||||||
|
|
||||||
=item-header
|
=item-header
|
||||||
border-bottom: 1px solid $module-border-color
|
border-bottom: 1px solid $module-border-color
|
||||||
|
|
@ -27,7 +28,7 @@
|
||||||
text-align: left
|
text-align: left
|
||||||
line-height: 100%
|
line-height: 100%
|
||||||
margin-bottom: 0
|
margin-bottom: 0
|
||||||
|
|
||||||
.item-links
|
.item-links
|
||||||
grid-area: links
|
grid-area: links
|
||||||
|
|
||||||
|
|
@ -41,32 +42,6 @@
|
||||||
abbr
|
abbr
|
||||||
cursor: help
|
cursor: help
|
||||||
|
|
||||||
.item-kind, .first-seen-at
|
|
||||||
padding: .25em .5em
|
|
||||||
border-radius: .25em
|
|
||||||
|
|
||||||
text-decoration: none
|
|
||||||
font-weight: bold
|
|
||||||
line-height: 1
|
|
||||||
|
|
||||||
background: #E2E8F0
|
|
||||||
color: #1A202C
|
|
||||||
|
|
||||||
.icon
|
|
||||||
vertical-align: middle
|
|
||||||
|
|
||||||
.item-kind
|
|
||||||
// These colors are copied from DTI 2020, for initial consistency!
|
|
||||||
// They're based on the Chakra UI colors, which I think are in turn the
|
|
||||||
// Bootstrap colors? Or something?
|
|
||||||
// NOTE: For the data-type=np case, we use the default gray colors.
|
|
||||||
&[data-type=nc]
|
|
||||||
background: #E9D8FD
|
|
||||||
color: #44337A
|
|
||||||
&[data-type=pb]
|
|
||||||
background: #FEEBC8
|
|
||||||
color: #7B341E
|
|
||||||
|
|
||||||
.support-form
|
.support-form
|
||||||
grid-area: support
|
grid-area: support
|
||||||
font-size: 85%
|
font-size: 85%
|
||||||
|
|
@ -100,21 +75,21 @@
|
||||||
font-size: 150%
|
font-size: 150%
|
||||||
font-weight: bold
|
font-weight: bold
|
||||||
margin-bottom: .75em
|
margin-bottom: .75em
|
||||||
|
|
||||||
.closet-hangers-ownership-groups
|
.closet-hangers-ownership-groups
|
||||||
+clearfix
|
+clearfix
|
||||||
margin-bottom: .5em
|
margin-bottom: .5em
|
||||||
|
|
||||||
div
|
div
|
||||||
float: left
|
float: left
|
||||||
margin: 0 5%
|
margin: 0 5%
|
||||||
text-align: left
|
text-align: left
|
||||||
width: 40%
|
width: 40%
|
||||||
|
|
||||||
li
|
li
|
||||||
list-style: none
|
list-style: none
|
||||||
word-wrap: break-word
|
word-wrap: break-word
|
||||||
|
|
||||||
label.unlisted
|
label.unlisted
|
||||||
font-style: italic
|
font-style: italic
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,7 +68,7 @@ class OutfitsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
@species_count = Species.count
|
@species_count = Species.count
|
||||||
|
|
||||||
@latest_contribution = Contribution.recent.first
|
@latest_contribution = Contribution.recent.first
|
||||||
Contribution.preload_contributeds_and_parents([@latest_contribution].compact)
|
Contribution.preload_contributeds_and_parents([@latest_contribution].compact)
|
||||||
|
|
||||||
|
|
@ -77,6 +77,54 @@ class OutfitsController < ApplicationController
|
||||||
@campaign = Fundraising::Campaign.current rescue nil
|
@campaign = Fundraising::Campaign.current rescue nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def new_v2
|
||||||
|
# Get selected species and color from params, or default to Blue Acara
|
||||||
|
@selected_species = params[:species] ? Species.find_by_id(params[:species]) : Species.find_by_name("Acara")
|
||||||
|
@selected_color = params[:color] ? Color.find_by_id(params[:color]) : Color.find_by_name("Blue")
|
||||||
|
|
||||||
|
# Load valid colors for the selected species (colors that have existing pet types)
|
||||||
|
@species = Species.alphabetical
|
||||||
|
@colors = @selected_species.compatible_colors
|
||||||
|
|
||||||
|
# Find the best pet type for this species+color combo
|
||||||
|
# If the exact combo doesn't exist, this will fall back to a simple color
|
||||||
|
@pet_type = PetType.for_species_and_color(
|
||||||
|
species_id: @selected_species.id,
|
||||||
|
color_id: @selected_color.id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Use the pet type's actual color as the selected color
|
||||||
|
# (might differ from requested color if we fell back to a simple color)
|
||||||
|
@selected_color = @pet_type&.color
|
||||||
|
|
||||||
|
# Load items from the objects[] parameter
|
||||||
|
item_ids = params[:objects] || []
|
||||||
|
items = Item.where(id: item_ids)
|
||||||
|
|
||||||
|
# Build the outfit
|
||||||
|
@outfit = Outfit.new(
|
||||||
|
pet_state: @pet_type&.canonical_pet_state,
|
||||||
|
worn_items: items,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Preload the manifests for all visible layers, so they load efficiently
|
||||||
|
# in parallel rather than sequentially when rendering
|
||||||
|
SwfAsset.preload_manifests(@outfit.visible_layers)
|
||||||
|
|
||||||
|
# Handle search mode
|
||||||
|
@search_mode = params[:q].present?
|
||||||
|
if @search_mode
|
||||||
|
search_filters = build_search_filters(params[:q], @outfit)
|
||||||
|
query_params = ActionController::Parameters.new(
|
||||||
|
search_filters.each_with_index.map { |filter, i| [i.to_s, filter] }.to_h
|
||||||
|
)
|
||||||
|
@query = Item::Search::Query.from_params(query_params, current_user)
|
||||||
|
@search_results = @query.results.paginate(page: params.dig(:q, :page), per_page: 30)
|
||||||
|
end
|
||||||
|
|
||||||
|
render layout: false
|
||||||
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@outfit = Outfit.find(params[:id])
|
@outfit = Outfit.find(params[:id])
|
||||||
|
|
||||||
|
|
@ -127,5 +175,52 @@ class OutfitsController < ApplicationController
|
||||||
:full_error_messages => @outfit.errors.full_messages},
|
:full_error_messages => @outfit.errors.full_messages},
|
||||||
:status => :bad_request
|
:status => :bad_request
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def build_search_filters(query_params, outfit)
|
||||||
|
filters = []
|
||||||
|
|
||||||
|
# Add name filter if present
|
||||||
|
if query_params[:name].present?
|
||||||
|
filters << { key: "name", value: query_params[:name] }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add item kind filter if present
|
||||||
|
if query_params[:item_kind].present?
|
||||||
|
case query_params[:item_kind]
|
||||||
|
when "nc"
|
||||||
|
filters << { key: "is_nc", value: "true" }
|
||||||
|
when "np"
|
||||||
|
filters << { key: "is_np", value: "true" }
|
||||||
|
when "pb"
|
||||||
|
filters << { key: "is_pb", value: "true" }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add zone filter if present
|
||||||
|
if query_params[:zone].present?
|
||||||
|
filters << { key: "occupied_zone_set_name", value: query_params[:zone] }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Always add auto-filter for items that fit the current pet
|
||||||
|
pet_type = outfit.pet_type
|
||||||
|
if pet_type
|
||||||
|
fit_filter = {
|
||||||
|
key: "fits",
|
||||||
|
value: {
|
||||||
|
species_id: pet_type.species_id.to_s,
|
||||||
|
color_id: pet_type.color_id.to_s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Include alt_style_id if present
|
||||||
|
if outfit.alt_style_id.present?
|
||||||
|
fit_filter[:value][:alt_style_id] = outfit.alt_style_id.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
filters << fit_filter
|
||||||
|
end
|
||||||
|
|
||||||
|
filters
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,30 @@ module OutfitsHelper
|
||||||
text_field_tag 'name', nil, options
|
text_field_tag 'name', nil, options
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Generate hidden fields to preserve outfit state in URL params.
|
||||||
|
# Use the `except` parameter to skip certain fields, e.g. to override
|
||||||
|
# them with specific values, like in the species/color picker.
|
||||||
|
def outfit_state_params(outfit = @outfit, except: [])
|
||||||
|
fields = []
|
||||||
|
|
||||||
|
fields << hidden_field_tag(:species, @outfit.species_id) unless except.include?(:species)
|
||||||
|
fields << hidden_field_tag(:color, @outfit.color_id) unless except.include?(:color)
|
||||||
|
|
||||||
|
unless except.include?(:worn_items)
|
||||||
|
outfit.worn_items.each do |item|
|
||||||
|
fields << hidden_field_tag('objects[]', item.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
unless except.include?(:q)
|
||||||
|
(params[:q] || {}).each do |key, value|
|
||||||
|
fields << hidden_field_tag("q[#{key}]", value) if value.present?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
safe_join fields
|
||||||
|
end
|
||||||
|
|
||||||
def outfit_viewer(...)
|
def outfit_viewer(...)
|
||||||
render partial: "outfit_viewer",
|
render partial: "outfit_viewer",
|
||||||
locals: parse_outfit_viewer_options(...)
|
locals: parse_outfit_viewer_options(...)
|
||||||
|
|
@ -75,8 +99,133 @@ module OutfitsHelper
|
||||||
locals: parse_outfit_viewer_options(...)
|
locals: parse_outfit_viewer_options(...)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Group outfit items by zone, applying smart multi-zone simplification.
|
||||||
|
# Returns an array of hashes: {zone:, items:}
|
||||||
|
# This matches the logic from wardrobe-2020's getZonesAndItems function.
|
||||||
|
def outfit_items_by_zone(outfit)
|
||||||
|
return [] if outfit.pet_type.nil?
|
||||||
|
|
||||||
|
# Get item appearances for this outfit
|
||||||
|
item_appearances = Item.appearances_for(
|
||||||
|
outfit.worn_items,
|
||||||
|
outfit.pet_type,
|
||||||
|
swf_asset_includes: [:zone]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Separate incompatible items (no layers for this pet)
|
||||||
|
compatible_items = []
|
||||||
|
incompatible_items = []
|
||||||
|
|
||||||
|
outfit.worn_items.each do |item|
|
||||||
|
appearance = item_appearances[item.id]
|
||||||
|
if appearance&.present?
|
||||||
|
compatible_items << {item: item, appearance: appearance}
|
||||||
|
else
|
||||||
|
incompatible_items << item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Group items by zone - multi-zone items appear in each zone
|
||||||
|
items_by_zone = Hash.new { |h, k| h[k] = [] }
|
||||||
|
zones_by_id = {}
|
||||||
|
|
||||||
|
compatible_items.each do |item_with_appearance|
|
||||||
|
item = item_with_appearance[:item]
|
||||||
|
appearance = item_with_appearance[:appearance]
|
||||||
|
|
||||||
|
# Get unique zones for this item (an item may have multiple assets per zone)
|
||||||
|
appearance.swf_assets.map(&:zone).uniq.each do |zone|
|
||||||
|
zones_by_id[zone.id] = zone
|
||||||
|
items_by_zone[zone.id] << item
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create zone groups with sorted items
|
||||||
|
zones_and_items = items_by_zone.map do |zone_id, items|
|
||||||
|
{
|
||||||
|
zone_id: zone_id,
|
||||||
|
zone_label: zones_by_id[zone_id].label,
|
||||||
|
items: items.sort_by { |item| item.name.downcase }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sort zone groups alphabetically by label, then by ID for tiebreaking
|
||||||
|
zones_and_items.sort_by! do |group|
|
||||||
|
[group[:zone_label].downcase, group[:zone_id]]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Apply multi-zone simplification: remove redundant single-item groups
|
||||||
|
zones_and_items = simplify_multi_zone_groups(zones_and_items)
|
||||||
|
|
||||||
|
# Add zone ID disambiguation for duplicate labels
|
||||||
|
zones_and_items = disambiguate_zone_labels(zones_and_items)
|
||||||
|
|
||||||
|
# Add incompatible items section if any
|
||||||
|
if incompatible_items.any?
|
||||||
|
zones_and_items << {
|
||||||
|
zone_id: nil,
|
||||||
|
zone_label: "Incompatible",
|
||||||
|
items: incompatible_items.sort_by { |item| item.name.downcase }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
zones_and_items
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
# Simplify zone groups by removing redundant single-item groups.
|
||||||
|
# Keep groups with multiple items (conflicts). For single-item groups,
|
||||||
|
# only keep them if the item doesn't appear in a multi-item group.
|
||||||
|
def simplify_multi_zone_groups(zones_and_items)
|
||||||
|
# Find groups with conflicts (multiple items)
|
||||||
|
groups_with_conflicts = zones_and_items.select { |g| g[:items].length > 1 }
|
||||||
|
|
||||||
|
# Track which items appear in conflict groups
|
||||||
|
items_with_conflicts = Set.new(
|
||||||
|
groups_with_conflicts.flat_map { |g| g[:items].map(&:id) }
|
||||||
|
)
|
||||||
|
|
||||||
|
# Track which items we've already shown
|
||||||
|
items_we_have_seen = Set.new
|
||||||
|
|
||||||
|
# Filter groups
|
||||||
|
zones_and_items.select do |group|
|
||||||
|
# Always keep groups with multiple items
|
||||||
|
if group[:items].length > 1
|
||||||
|
group[:items].each { |item| items_we_have_seen.add(item.id) }
|
||||||
|
true
|
||||||
|
else
|
||||||
|
# For single-item groups, only keep if:
|
||||||
|
# - Item hasn't been seen yet AND
|
||||||
|
# - Item won't appear in a conflict group
|
||||||
|
item = group[:items].first
|
||||||
|
item_id = item.id
|
||||||
|
|
||||||
|
if items_we_have_seen.include?(item_id) || items_with_conflicts.include?(item_id)
|
||||||
|
false
|
||||||
|
else
|
||||||
|
items_we_have_seen.add(item_id)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add zone IDs to labels when there are duplicates
|
||||||
|
def disambiguate_zone_labels(zones_and_items)
|
||||||
|
label_counts = zones_and_items.group_by { |g| g[:zone_label] }
|
||||||
|
.transform_values(&:count)
|
||||||
|
|
||||||
|
zones_and_items.each do |group|
|
||||||
|
if label_counts[group[:zone_label]] > 1
|
||||||
|
group[:zone_label] = "#{group[:zone_label]} (##{group[:zone_id]})"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
zones_and_items
|
||||||
|
end
|
||||||
|
|
||||||
def parse_outfit_viewer_options(
|
def parse_outfit_viewer_options(
|
||||||
outfit=nil, pet_state: nil, preferred_image_format: :png, **html_options
|
outfit=nil, pet_state: nil, preferred_image_format: :png, **html_options
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -549,6 +549,22 @@ class Item < ApplicationRecord
|
||||||
return [] if empty?
|
return [] if empty?
|
||||||
([item] + swf_assets).map(&:restricted_zone_ids).flatten.uniq.sort
|
([item] + swf_assets).map(&:restricted_zone_ids).flatten.uniq.sort
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Check if this appearance is compatible with another appearance.
|
||||||
|
# Two appearances are incompatible if:
|
||||||
|
# 1. They occupy the same zone (can't wear two items in same slot)
|
||||||
|
# 2. One restricts a zone the other occupies (e.g., hat restricts hair zone)
|
||||||
|
def compatible_with?(other)
|
||||||
|
occupied = occupied_zone_ids
|
||||||
|
other_occupied = other.occupied_zone_ids
|
||||||
|
restricted = restricted_zone_ids
|
||||||
|
other_restricted = other.restricted_zone_ids
|
||||||
|
|
||||||
|
# Check for zone conflicts
|
||||||
|
(occupied & other_occupied).empty? &&
|
||||||
|
(restricted & other_occupied).empty? &&
|
||||||
|
(other_restricted & occupied).empty?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
Appearance::Body = Struct.new(:id, :species) do
|
Appearance::Body = Struct.new(:id, :species) do
|
||||||
include ActiveModel::Serializers::JSON
|
include ActiveModel::Serializers::JSON
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,8 @@ class Outfit < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def visible_layers
|
def visible_layers
|
||||||
|
return [] if pet_state.nil?
|
||||||
|
|
||||||
# TODO: This method doesn't currently handle alt styles! If the outfit has
|
# TODO: This method doesn't currently handle alt styles! If the outfit has
|
||||||
# an alt_style, we should use its layers instead of pet_state layers, and
|
# an alt_style, we should use its layers instead of pet_state layers, and
|
||||||
# filter items to only those with body_id=0. This isn't needed yet because
|
# filter items to only those with body_id=0. This isn't needed yet because
|
||||||
|
|
@ -279,4 +281,55 @@ class Outfit < ApplicationRecord
|
||||||
i += 1
|
i += 1
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# When creating Outfit copies, include items. They're considered a basic
|
||||||
|
# property of the record, in the grand scheme of things, despite being
|
||||||
|
# associations.
|
||||||
|
def dup
|
||||||
|
super.tap do |outfit|
|
||||||
|
outfit.worn_items = self.worn_items
|
||||||
|
outfit.closeted_items = self.closeted_items
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a copy of this outfit, but *not* wearing the given item.
|
||||||
|
def without_item(item)
|
||||||
|
dup.tap { |o| o.worn_items.delete(item) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a copy of this outfit, additionally wearing the given item.
|
||||||
|
# Automatically moves any incompatible worn items to the closet.
|
||||||
|
def with_item(item)
|
||||||
|
dup.tap do |o|
|
||||||
|
# Skip if item is nil, already worn, or outfit has no pet_state
|
||||||
|
next if item.nil? || o.worn_item_ids.include?(item.id) || o.pet_state.nil?
|
||||||
|
|
||||||
|
# Load appearances for the new item and all currently worn items
|
||||||
|
all_items = o.worn_items + [item]
|
||||||
|
appearances = Item.appearances_for(all_items, o.pet_type,
|
||||||
|
swf_asset_includes: [:zone])
|
||||||
|
|
||||||
|
new_item_appearance = appearances[item.id]
|
||||||
|
|
||||||
|
# If the new item has no appearance (doesn't fit this pet), skip it
|
||||||
|
next if new_item_appearance.empty?
|
||||||
|
|
||||||
|
# Find items that conflict with the new item
|
||||||
|
conflicting_items = o.worn_items.select do |worn_item|
|
||||||
|
worn_appearance = appearances[worn_item.id]
|
||||||
|
# Empty appearances are always compatible
|
||||||
|
!worn_appearance.empty? &&
|
||||||
|
!new_item_appearance.compatible_with?(worn_appearance)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Move conflicting items to closet
|
||||||
|
conflicting_items.each do |conflicting_item|
|
||||||
|
o.worn_items.delete(conflicting_item)
|
||||||
|
o.closeted_items << conflicting_item unless o.closeted_item_ids.include?(conflicting_item.id)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Add the new item
|
||||||
|
o.worn_items << item
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,26 @@ class PetType < ApplicationRecord
|
||||||
random_pet_types
|
random_pet_types
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Given a species ID and color ID, return the best matching PetType.
|
||||||
|
#
|
||||||
|
# If the exact species+color combo exists, return it.
|
||||||
|
# Otherwise, find the best fallback for that species:
|
||||||
|
# - Prefer the requested color if available
|
||||||
|
# - Otherwise prefer simple colors (basic > standard > alphabetical)
|
||||||
|
#
|
||||||
|
# This matches the wardrobe behavior where we automatically switch to a valid
|
||||||
|
# color when the user selects a species that doesn't support their current color.
|
||||||
|
#
|
||||||
|
# Returns the PetType, or nil if no pet types exist for this species.
|
||||||
|
def self.for_species_and_color(species_id:, color_id:)
|
||||||
|
return nil if species_id.nil?
|
||||||
|
|
||||||
|
where(species_id: species_id)
|
||||||
|
.preferring_color(color_id)
|
||||||
|
.preferring_simple
|
||||||
|
.first
|
||||||
|
end
|
||||||
|
|
||||||
def as_json(options={})
|
def as_json(options={})
|
||||||
super({
|
super({
|
||||||
only: [:id],
|
only: [:id],
|
||||||
|
|
|
||||||
|
|
@ -34,4 +34,13 @@ class Species < ApplicationRecord
|
||||||
def self.param_to_id(param)
|
def self.param_to_id(param)
|
||||||
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id
|
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Get all colors that are compatible with this species (have pet types)
|
||||||
|
# Returns an ActiveRecord::Relation of Color records
|
||||||
|
def compatible_colors
|
||||||
|
Color.alphabetical
|
||||||
|
.joins(:pet_types)
|
||||||
|
.where(pet_types: { species_id: id })
|
||||||
|
.distinct
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -9,26 +9,8 @@
|
||||||
= image_tag item.thumbnail_url, class: 'item-thumbnail'
|
= image_tag item.thumbnail_url, class: 'item-thumbnail'
|
||||||
%h2.item-name= item.name
|
%h2.item-name= item.name
|
||||||
%nav.item-links
|
%nav.item-links
|
||||||
- if item.currently_in_mall?
|
= render "items/badges/kind", item: item
|
||||||
= link_to "https://ncmall.neopets.com/", class: "item-kind", data: {type: "nc"},
|
= render "items/badges/first_seen", item: item
|
||||||
title: "Currently in NC Mall!", target: "_blank" do
|
|
||||||
= cart_icon alt: "Buy"
|
|
||||||
#{item.current_nc_price} NC
|
|
||||||
- elsif item.nc?
|
|
||||||
%abbr.item-kind{'data-type' => 'nc', title: t('items.show.item_kinds.nc.description')}
|
|
||||||
= t('items.show.item_kinds.nc.label')
|
|
||||||
- elsif item.pb?
|
|
||||||
%abbr.item-kind{'data-type' => 'pb', title: t('items.show.item_kinds.pb.description')}
|
|
||||||
= t('items.show.item_kinds.pb.label')
|
|
||||||
- else
|
|
||||||
%abbr.item-kind{'data-type' => 'np', title: t('items.show.item_kinds.np.description')}
|
|
||||||
= t('items.show.item_kinds.np.label')
|
|
||||||
|
|
||||||
- if item.created_at?
|
|
||||||
%time.first-seen-at{
|
|
||||||
datetime: item.created_at.iso8601,
|
|
||||||
title: "First seen on #{item.created_at.to_date.to_fs(:long)}",
|
|
||||||
}= time_with_only_month_if_old item.created_at
|
|
||||||
|
|
||||||
= link_to t('items.show.resources.jn_items'), jn_items_url_for(item)
|
= link_to t('items.show.resources.jn_items'), jn_items_url_for(item)
|
||||||
= link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item)
|
= link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item)
|
||||||
|
|
|
||||||
13
app/views/items/badges/_first_seen.html.haml
Normal file
13
app/views/items/badges/_first_seen.html.haml
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
-# Renders a "first seen" timestamp badge for an item
|
||||||
|
-#
|
||||||
|
-# Usage:
|
||||||
|
-# = render "items/badges/first_seen", item: @item
|
||||||
|
-#
|
||||||
|
-# Shows when the item was first added to the database.
|
||||||
|
-# Only renders if the item has a created_at timestamp.
|
||||||
|
|
||||||
|
- if item.created_at?
|
||||||
|
%time.item-badge.first-seen-at{
|
||||||
|
datetime: item.created_at.iso8601,
|
||||||
|
title: "First seen on #{item.created_at.to_date.to_fs(:long)}",
|
||||||
|
}= time_with_only_month_if_old item.created_at
|
||||||
25
app/views/items/badges/_kind.html.haml
Normal file
25
app/views/items/badges/_kind.html.haml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
-# Renders an item kind badge (NC/NP/PB)
|
||||||
|
-#
|
||||||
|
-# Usage:
|
||||||
|
-# = render "items/item_kind_badge", item: @item
|
||||||
|
-#
|
||||||
|
-# The badge shows:
|
||||||
|
-# - For NC Mall items: clickable link with price
|
||||||
|
-# - For NC items: purple "NC" badge
|
||||||
|
-# - For PB items: orange "PB" badge
|
||||||
|
-# - For NP items: gray "NP" badge
|
||||||
|
|
||||||
|
- if item.currently_in_mall?
|
||||||
|
= link_to "https://ncmall.neopets.com/", class: "item-badge", data: {item_kind: "nc"},
|
||||||
|
title: "Currently in NC Mall!", target: "_blank" do
|
||||||
|
= cart_icon alt: "Buy"
|
||||||
|
#{item.current_nc_price} NC
|
||||||
|
- elsif item.nc?
|
||||||
|
%abbr.item-badge{data: {item_kind: "nc"}, title: t('items.show.item_kinds.nc.description')}
|
||||||
|
= t('items.show.item_kinds.nc.label')
|
||||||
|
- elsif item.pb?
|
||||||
|
%abbr.item-badge{data: {item_kind: "pb"}, title: t('items.show.item_kinds.pb.description')}
|
||||||
|
= t('items.show.item_kinds.pb.label')
|
||||||
|
- else
|
||||||
|
%abbr.item-badge{data: {item_kind: "np"}, title: t('items.show.item_kinds.np.description')}
|
||||||
|
= t('items.show.item_kinds.np.label')
|
||||||
|
|
@ -131,4 +131,5 @@
|
||||||
- content_for :javascripts do
|
- content_for :javascripts do
|
||||||
= javascript_include_tag "idiomorph", async: true
|
= javascript_include_tag "idiomorph", async: true
|
||||||
= javascript_include_tag "outfit-viewer", async: true
|
= javascript_include_tag "outfit-viewer", async: true
|
||||||
|
= javascript_include_tag "species-color-picker", async: true
|
||||||
= javascript_include_tag "items/show", async: true
|
= javascript_include_tag "items/show", async: true
|
||||||
|
|
|
||||||
28
app/views/outfits/_search_results.html.haml
Normal file
28
app/views/outfits/_search_results.html.haml
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
.search-results
|
||||||
|
.search-results-header
|
||||||
|
= button_to wardrobe_v2_path, method: :get, class: "back-button" do
|
||||||
|
← Back to outfit
|
||||||
|
= outfit_state_params except: [:q]
|
||||||
|
|
||||||
|
- if @search_results.any?
|
||||||
|
= will_paginate @search_results, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] }
|
||||||
|
|
||||||
|
%ul.search-results-list
|
||||||
|
- @search_results.each do |item|
|
||||||
|
%li.item-card
|
||||||
|
.item-thumbnail
|
||||||
|
= image_tag item.thumbnail_url, alt: item.name, loading: "lazy"
|
||||||
|
.item-info
|
||||||
|
.item-name= item.name
|
||||||
|
.item-badges
|
||||||
|
= render "items/badges/kind", item: item
|
||||||
|
= render "items/badges/first_seen", item: item
|
||||||
|
= button_to wardrobe_v2_path, method: :get, class: "item-add-button", title: "Add #{item.name}", "aria-label": "Add #{item.name}" do
|
||||||
|
➕
|
||||||
|
= outfit_state_params @outfit.with_item(item)
|
||||||
|
|
||||||
|
= will_paginate @search_results, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] }
|
||||||
|
|
||||||
|
- else
|
||||||
|
.empty-state
|
||||||
|
%p No matching items found. Try a different search term, or browse items on the main site.
|
||||||
71
app/views/outfits/new_v2.html.haml
Normal file
71
app/views/outfits/new_v2.html.haml
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
- title "Wardrobe v2"
|
||||||
|
|
||||||
|
!!! 5
|
||||||
|
%html
|
||||||
|
%head
|
||||||
|
%meta{charset: 'utf-8'}
|
||||||
|
%meta{name: 'viewport', content: 'width=device-width, initial-scale=1'}
|
||||||
|
%title= yield :title
|
||||||
|
%link{href: image_path('favicon.png'), rel: 'icon'}
|
||||||
|
= stylesheet_link_tag "application/hanger-spinner"
|
||||||
|
= stylesheet_link_tag "application/outfit-viewer"
|
||||||
|
= page_stylesheet_link_tag "outfits/new_v2"
|
||||||
|
= javascript_include_tag "application", async: true
|
||||||
|
= javascript_include_tag "idiomorph", async: true
|
||||||
|
= javascript_include_tag "outfit-viewer", async: true
|
||||||
|
= javascript_include_tag "species-color-picker", async: true
|
||||||
|
= javascript_include_tag "outfits/new_v2", async: true
|
||||||
|
= csrf_meta_tags
|
||||||
|
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
|
||||||
|
%body.wardrobe-v2
|
||||||
|
.wardrobe-container
|
||||||
|
.outfit-preview-section
|
||||||
|
- if @pet_type.nil?
|
||||||
|
.no-preview-message
|
||||||
|
%p
|
||||||
|
We haven't seen this kind of pet before! Try a different species/color
|
||||||
|
combination.
|
||||||
|
- else
|
||||||
|
= outfit_viewer @outfit
|
||||||
|
|
||||||
|
%species-color-picker
|
||||||
|
= form_with url: wardrobe_v2_path, method: :get do |f|
|
||||||
|
= outfit_state_params except: [:color, :species]
|
||||||
|
= select_tag :color,
|
||||||
|
options_from_collection_for_select(@colors, "id", "human_name",
|
||||||
|
@selected_color&.id),
|
||||||
|
"aria-label": "Pet color"
|
||||||
|
= select_tag :species,
|
||||||
|
options_from_collection_for_select(@species, "id", "human_name",
|
||||||
|
@selected_species&.id),
|
||||||
|
"aria-label": "Pet species"
|
||||||
|
= submit_tag "Go", name: nil
|
||||||
|
|
||||||
|
.outfit-controls-section
|
||||||
|
%h1 Customize your pet
|
||||||
|
|
||||||
|
= form_with url: wardrobe_v2_path, method: :get, class: "item-search-form" do |f|
|
||||||
|
= outfit_state_params
|
||||||
|
= f.text_field "q[name]", placeholder: "Search for items...", value: params.dig(:q, :name), "aria-label": "Search for items"
|
||||||
|
= f.submit "Search"
|
||||||
|
|
||||||
|
- if @search_mode
|
||||||
|
= render "search_results"
|
||||||
|
- elsif @outfit.worn_items.any?
|
||||||
|
.worn-items
|
||||||
|
- outfit_items_by_zone(@outfit).each do |zone_group|
|
||||||
|
.zone-group
|
||||||
|
%h3.zone-label= zone_group[:zone_label]
|
||||||
|
%ul.items-list
|
||||||
|
- zone_group[:items].each do |item|
|
||||||
|
%li.item-card
|
||||||
|
.item-thumbnail
|
||||||
|
= image_tag item.thumbnail_url, alt: item.name, loading: "lazy"
|
||||||
|
.item-info
|
||||||
|
.item-name= item.name
|
||||||
|
.item-badges
|
||||||
|
= render "items/badges/kind", item: item
|
||||||
|
= render "items/badges/first_seen", item: item
|
||||||
|
= button_to wardrobe_v2_path, method: :get, class: "item-remove-button", title: "Remove #{item.name}", "aria-label": "Remove #{item.name}" do
|
||||||
|
❌
|
||||||
|
= outfit_state_params @outfit.without_item(item)
|
||||||
|
|
@ -10,6 +10,7 @@ OpenneoImpressItems::Application.routes.draw do
|
||||||
# TODO: It's a bit silly that outfits/new points to outfits#edit.
|
# TODO: It's a bit silly that outfits/new points to outfits#edit.
|
||||||
# Should we refactor the controller/view structure here?
|
# Should we refactor the controller/view structure here?
|
||||||
get '/outfits/new', to: 'outfits#edit', as: :wardrobe
|
get '/outfits/new', to: 'outfits#edit', as: :wardrobe
|
||||||
|
get '/outfits/new/v2', to: 'outfits#new_v2', as: :wardrobe_v2
|
||||||
get '/wardrobe' => redirect('/outfits/new')
|
get '/wardrobe' => redirect('/outfits/new')
|
||||||
get '/start/:color_name/:species_name' => 'outfits#start'
|
get '/start/:color_name/:species_name' => 'outfits#start'
|
||||||
|
|
||||||
|
|
|
||||||
994
docs/wardrobe-v2-migration.md
Normal file
994
docs/wardrobe-v2-migration.md
Normal file
|
|
@ -0,0 +1,994 @@
|
||||||
|
# Wardrobe V2 Migration Status
|
||||||
|
|
||||||
|
This document tracks the status of Wardrobe V2, a ground-up rewrite of the outfit editor using Rails + Turbo, replacing the React + GraphQL system embedded from Impress 2020.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Replace the complex React outfit editor (`app/javascript/wardrobe-2020/`) with a simpler Rails/Turbo implementation that:
|
||||||
|
- Eliminates dependency on Impress 2020's GraphQL API
|
||||||
|
- Uses progressive enhancement (works without JavaScript)
|
||||||
|
- Leverages Web Components for interactive features
|
||||||
|
- Reduces frontend complexity and maintenance burden
|
||||||
|
- Eventually enables full deprecation of the Impress 2020 service
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
|
||||||
|
**Wardrobe V2 is in early prototype/proof-of-concept stage.** It's accessible at `/outfits/new/v2` but is not yet linked from the main UI.
|
||||||
|
|
||||||
|
### What's Implemented
|
||||||
|
|
||||||
|
#### Core Infrastructure
|
||||||
|
|
||||||
|
**Route & Controller** ([outfits_controller.rb:80-115](app/controllers/outfits_controller.rb#L80-L115))
|
||||||
|
- `GET /outfits/new/v2` - Main wardrobe endpoint
|
||||||
|
- Takes URL params: `species`, `color`, `objects[]` (item IDs)
|
||||||
|
- Returns full HTML page (no layout, designed to work standalone)
|
||||||
|
- Defaults to Blue Acara if no pet specified
|
||||||
|
|
||||||
|
**View Layer** ([new_v2.html.haml](app/views/outfits/new_v2.html.haml))
|
||||||
|
- Full-page layout with preview (left) and controls (right)
|
||||||
|
- Responsive: stacks vertically on mobile (< 800px)
|
||||||
|
- Uses existing `outfit_viewer` partial for rendering
|
||||||
|
- Custom CSS in [outfits/new_v2.css](app/assets/stylesheets/outfits/new_v2.css)
|
||||||
|
- Minimal JavaScript in [outfits/new_v2.js](app/assets/javascripts/outfits/new_v2.js)
|
||||||
|
|
||||||
|
**Pet Selection** ([new_v2.html.haml:31-42](app/views/outfits/new_v2.html.haml#L31-L42))
|
||||||
|
- Species/color picker using `<species-color-picker>` web component
|
||||||
|
- Floats over preview area (bottom), reveals on hover/focus
|
||||||
|
- Progressive enhancement: submit button appears if JS slow/disabled
|
||||||
|
- Auto-submits form on change when JS loaded
|
||||||
|
- Filters colors to only those compatible with selected species
|
||||||
|
- Advanced fallback: if species+color combo doesn't exist, falls back to simple color ([outfits_controller.rb:89-98](app/controllers/outfits_controller.rb#L89-L98))
|
||||||
|
|
||||||
|
**Item Display** ([new_v2.html.haml:47-64](app/views/outfits/new_v2.html.haml#L47-L64))
|
||||||
|
- Groups worn items by zone (Hat, Jacket, Wings, etc.)
|
||||||
|
- Smart multi-zone simplification: hides redundant single-item zones
|
||||||
|
- Shows items that occupy multiple zones in conflict zones only
|
||||||
|
- Handles zone label disambiguation (adds IDs when duplicates exist)
|
||||||
|
- Separates incompatible items (wrong body_id) into "Incompatible" section
|
||||||
|
- Displays item thumbnails, names, and badges (NC/NP, first seen date)
|
||||||
|
- Alphabetical sorting within zones
|
||||||
|
|
||||||
|
**Item Search** ([new_v2.html.haml:47-50](app/views/outfits/new_v2.html.haml#L47-L50))
|
||||||
|
- Search form at top of controls section
|
||||||
|
- Auto-filters to items that fit current pet (species + color + alt_style)
|
||||||
|
- Uses `Item::Search::Query.from_params` for structured search
|
||||||
|
- Toggles between search results and worn items views (full page refresh)
|
||||||
|
- Pagination with 30 items per page using `q[page]` parameter
|
||||||
|
- All search state scoped under `q[...]` params (name, page, etc.)
|
||||||
|
- "Back to outfit" button to exit search
|
||||||
|
|
||||||
|
**Item Addition** ([_search_results.html.haml](app/views/outfits/_search_results.html.haml))
|
||||||
|
- Add button (➕) on each search result item
|
||||||
|
- Adds item to outfit via GET request with updated `objects[]` params
|
||||||
|
- Button hidden by default, appears on hover/focus
|
||||||
|
- Preserves search state when adding items
|
||||||
|
- Uses `outfit.with_item(item)` helper to generate new state
|
||||||
|
|
||||||
|
**Item Removal** ([new_v2.html.haml:70-72](app/views/outfits/new_v2.html.haml#L70-L72))
|
||||||
|
- Remove button (❌) on each worn item
|
||||||
|
- Removes item from outfit via GET request with updated `objects[]` params
|
||||||
|
- Button hidden by default, appears on hover/focus
|
||||||
|
- Uses `outfit.without_item(item)` helper to generate new state
|
||||||
|
|
||||||
|
**State Management** ([outfits_helper.rb:68-90](app/helpers/outfits_helper.rb#L68-L90))
|
||||||
|
- All state lives in URL params (no client-side state)
|
||||||
|
- `outfit_state_params` helper generates hidden fields for outfit state
|
||||||
|
- Preserves: species, color, worn items (`objects[]`), search query (`q[...]`)
|
||||||
|
- Can exclude specific params via `except:` (e.g., to override species/color, or clear search)
|
||||||
|
- Every action generates new URL via GET request
|
||||||
|
|
||||||
|
#### Supporting Helpers
|
||||||
|
|
||||||
|
**`outfit_items_by_zone`** ([outfits_helper.rb:96-167](app/helpers/outfits_helper.rb#L96-L167))
|
||||||
|
- Core grouping logic for items by zone
|
||||||
|
- Matches wardrobe-2020's `getZonesAndItems` behavior
|
||||||
|
- Returns array of `{zone_id:, zone_label:, items:}` hashes
|
||||||
|
- Extensively tested in [outfits_helper_spec.rb](spec/helpers/outfits_helper_spec.rb)
|
||||||
|
|
||||||
|
**`outfit_viewer`** ([outfits_helper.rb:86-89](app/helpers/outfits_helper.rb#L86-L89))
|
||||||
|
- Renders `<outfit-viewer>` web component
|
||||||
|
- Displays pet + item layers using HTML5 Canvas/iframes
|
||||||
|
- Reused from existing codebase (also used on item pages, alt style pages, etc.)
|
||||||
|
|
||||||
|
**Web Components**
|
||||||
|
- `<species-color-picker>` - Auto-submit form on change ([species-color-picker.js](app/assets/javascripts/species-color-picker.js))
|
||||||
|
- `<outfit-viewer>` - Pet/item layer rendering ([outfit-viewer.js](app/assets/javascripts/outfit-viewer.js))
|
||||||
|
- `<outfit-layer>` - Individual layer loading/error states
|
||||||
|
|
||||||
|
#### Model Support
|
||||||
|
|
||||||
|
**`Outfit#without_item`** ([outfit.rb:296-298](app/models/outfit.rb#L296-L298))
|
||||||
|
- Creates a duplicate outfit without the specified item
|
||||||
|
- Used for remove button state generation
|
||||||
|
|
||||||
|
**`Outfit#with_item`** ([outfit.rb:300-303](app/models/outfit.rb#L300-L303))
|
||||||
|
- Creates a duplicate outfit with the specified item added
|
||||||
|
- Used for add button state generation in search results
|
||||||
|
- Prevents duplicate items (checks if item already worn)
|
||||||
|
|
||||||
|
**`Outfit#visible_layers`** ([outfit.rb:172-192](app/models/outfit.rb#L172-L192))
|
||||||
|
- Returns array of `SwfAsset` layers to render
|
||||||
|
- Combines pet biology layers + compatible item layers
|
||||||
|
- Filters by zone restrictions
|
||||||
|
- Note: Doesn't currently handle alt styles (TODO in code)
|
||||||
|
|
||||||
|
**`Item.appearances_for`**
|
||||||
|
- Batch-loads item appearances for multiple items on a pet type
|
||||||
|
- Returns hash of `{item_id => Appearance}` structs
|
||||||
|
- Used by `outfit_items_by_zone` to determine compatibility
|
||||||
|
|
||||||
|
**`Item::Search::Query.from_params`** ([item/search/query.rb](app/models/item/search/query.rb))
|
||||||
|
- Structured search query builder (vs. `from_text` which parses strings)
|
||||||
|
- Takes indexed hash of filters with `key`, `value`, `is_positive`
|
||||||
|
- Supported filters: `name`, `is_nc`, `is_np`, `is_pb`, `fits`, `occupied_zone_set_name`, etc.
|
||||||
|
- Used by Wardrobe V2 to auto-filter items by current pet compatibility
|
||||||
|
|
||||||
|
### What's NOT Implemented Yet
|
||||||
|
|
||||||
|
Below is a comprehensive comparison with the full feature set of Wardrobe 2020 (React version).
|
||||||
|
|
||||||
|
#### Critical Missing Features
|
||||||
|
|
||||||
|
**Item Search & Addition**
|
||||||
|
- ✅ Basic search UI implemented (text search only)
|
||||||
|
- ✅ Item results display with pagination (30 per page)
|
||||||
|
- ✅ Add items to outfit from search results
|
||||||
|
- ✅ Auto-filtering to items that fit current pet
|
||||||
|
- ✅ Empty state messages
|
||||||
|
- ❌ Still missing from Wardrobe 2020:
|
||||||
|
- Inline search syntax (`is:nc`, `fits:blue-acara`, etc.) - currently only supports plain text
|
||||||
|
- Advanced filter UI (NC/NP/PB toggles, zone selector, ownership filters)
|
||||||
|
- Filter chips display showing active filters
|
||||||
|
- Autosuggest/autocomplete
|
||||||
|
- Keyboard navigation in search results (Up/Down arrows, Escape, Enter)
|
||||||
|
- Preloading adjacent pages for faster pagination
|
||||||
|
- NC Styles intelligent hints
|
||||||
|
- Item restoration logic (restoring previous items when trying on conflicts)
|
||||||
|
|
||||||
|
**Outfit Saving/Loading**
|
||||||
|
- ❌ No save button
|
||||||
|
- ❌ No editable outfit name field
|
||||||
|
- ❌ No load existing outfit capability
|
||||||
|
- ❌ No user authentication integration
|
||||||
|
- ❌ Missing from Wardrobe 2020:
|
||||||
|
- Auto-save with debounce
|
||||||
|
- "Saving..." / "Saved" indicator
|
||||||
|
- Version tracking
|
||||||
|
- Navigation blocking for unsaved changes
|
||||||
|
- Owner-only editing restrictions
|
||||||
|
- Outfit menu (Edit a Copy, Rename, Delete)
|
||||||
|
- Shopping list link (Items Sources page)
|
||||||
|
|
||||||
|
**Pose/Emotion Selection**
|
||||||
|
- ✅ Has: Species and color pickers
|
||||||
|
- ❌ No pose picker UI
|
||||||
|
- ❌ Locked to canonical pose for pet type
|
||||||
|
- ❌ Missing from Wardrobe 2020:
|
||||||
|
- Tabbed interface (Expressions tab, Styles tab)
|
||||||
|
- Expression grid (3×2 matrix: Happy/Sad/Sick × Masc/Fem)
|
||||||
|
- Visual pose preview thumbnails
|
||||||
|
- Unconverted (UC) pose option with warning
|
||||||
|
- Pose availability indicators (grayed out with "?")
|
||||||
|
- Auto-recovery when pose becomes invalid
|
||||||
|
- Alt Styles/Pet Styles picker (Styles tab)
|
||||||
|
- "Default" option to return to normal appearance
|
||||||
|
- Visual thumbnails for each alt style
|
||||||
|
- Link to Rainbow Pool Styles info
|
||||||
|
|
||||||
|
**Pet Loading**
|
||||||
|
- ❌ No "load my pet" feature (pet name lookup)
|
||||||
|
- ❌ Can only use species/color pickers
|
||||||
|
- ❌ No modeling integration
|
||||||
|
|
||||||
|
**Preview Controls**
|
||||||
|
- ✅ Has: Basic outfit viewer rendering
|
||||||
|
- ❌ No overlay controls
|
||||||
|
- ❌ Missing from Wardrobe 2020:
|
||||||
|
- Back button (to homepage/Your Outfits)
|
||||||
|
- Play/Pause animation button (with localStorage persistence)
|
||||||
|
- Download outfit as PNG (with pre-generation on hover)
|
||||||
|
- Copy link to clipboard (with "Copied!" confirmation)
|
||||||
|
- Settings popover:
|
||||||
|
- Hi-res mode toggle (SVG vs PNG)
|
||||||
|
- Use DTI's image archive toggle
|
||||||
|
- HTML5 conversion badge (green checkmark / warnings)
|
||||||
|
- Known glitches badge (lists specific issues)
|
||||||
|
- Right-click context menu (Download, Layers info modal)
|
||||||
|
- Auto-hide controls on desktop, always visible on touch
|
||||||
|
- Focus lock on touch devices
|
||||||
|
|
||||||
|
**Advanced Item Features**
|
||||||
|
- ✅ Has: Item badges (NC/NP, first seen)
|
||||||
|
- ✅ Has: Remove item button
|
||||||
|
- ❌ Missing from Wardrobe 2020:
|
||||||
|
- Wear/unwear toggle (click to toggle, radio button behavior)
|
||||||
|
- Item info button (opens item page in new tab)
|
||||||
|
- "You own this" badge (for logged-in users)
|
||||||
|
- "You want this" badge (for logged-in users)
|
||||||
|
- Zone badges (shows occupied zones on item)
|
||||||
|
- Restricted zone badges
|
||||||
|
- Incompatible items tooltip (explains why item doesn't fit)
|
||||||
|
- Alt Style incompatibility indicators
|
||||||
|
- Smooth animations (fade out and collapse on removal)
|
||||||
|
|
||||||
|
**User Features**
|
||||||
|
- ❌ No user authentication
|
||||||
|
- ❌ No closet integration
|
||||||
|
- ❌ No ownership tracking
|
||||||
|
- ❌ No wishlist tracking
|
||||||
|
- ❌ Can't filter search by owned/wanted items
|
||||||
|
|
||||||
|
**URL State**
|
||||||
|
- ✅ Has: Basic state (species, color, items)
|
||||||
|
- ❌ Missing parameters:
|
||||||
|
- `name` - Outfit name
|
||||||
|
- `pose` - Pose string (e.g., HAPPY_FEM)
|
||||||
|
- `style` - Alt Style ID
|
||||||
|
- `state` - Appearance ID (pose version pinning)
|
||||||
|
- `closet[]` - Closeted items array
|
||||||
|
- ❌ No browser back/forward support (full page reloads)
|
||||||
|
- ❌ No legacy URL format support (#params)
|
||||||
|
|
||||||
|
#### UI/UX Gaps
|
||||||
|
|
||||||
|
**Visual Polish**
|
||||||
|
- Basic styling, needs design refinement
|
||||||
|
- No loading spinners with configurable delay
|
||||||
|
- No skeleton screens
|
||||||
|
- No smooth transitions (fade in/out as layers load)
|
||||||
|
- No error boundaries
|
||||||
|
- No toast notifications
|
||||||
|
- Species/color picker styling is functional but basic
|
||||||
|
- No dark mode support
|
||||||
|
|
||||||
|
**Accessibility**
|
||||||
|
- ❌ Missing from Wardrobe 2020:
|
||||||
|
- ARIA labels on all interactive elements
|
||||||
|
- VisuallyHidden screen reader helpers
|
||||||
|
- Semantic heading hierarchy
|
||||||
|
- Landmark regions
|
||||||
|
- Skip links between sections
|
||||||
|
- High color contrast
|
||||||
|
- Comprehensive keyboard shortcuts
|
||||||
|
|
||||||
|
**Performance**
|
||||||
|
- ❌ No optimistic updates (every change is full page navigation)
|
||||||
|
- ❌ Could benefit from Turbo Frames for partial updates
|
||||||
|
- ❌ No prefetching/preloading
|
||||||
|
- ❌ Missing from Wardrobe 2020:
|
||||||
|
- React.memo optimizations
|
||||||
|
- Object caching to prevent re-renders
|
||||||
|
- GraphQL query caching
|
||||||
|
- Image preloading
|
||||||
|
- LRU caches for expensive computations
|
||||||
|
- Low FPS detection and auto-pause
|
||||||
|
- Network error recovery
|
||||||
|
- Incremental loading
|
||||||
|
- Non-blocking loading overlays
|
||||||
|
|
||||||
|
**Mobile Experience**
|
||||||
|
- ✅ Has: Basic responsive layout (stacks vertically)
|
||||||
|
- ❌ Touch interactions not optimized
|
||||||
|
- ❌ Species/color picker hover state doesn't work well on touch
|
||||||
|
- ❌ Missing from Wardrobe 2020:
|
||||||
|
- Large touch targets
|
||||||
|
- Always-visible action buttons on touch devices
|
||||||
|
- Adapted control layouts for small screens
|
||||||
|
- No hover-only interactions
|
||||||
|
|
||||||
|
#### Advanced Features Not Implemented
|
||||||
|
|
||||||
|
**Conflict Detection & Resolution**
|
||||||
|
- ❌ No automatic zone conflict resolution
|
||||||
|
- ❌ No smart item restoration when unwearing
|
||||||
|
- ❌ No closet panel for conflicted items
|
||||||
|
- ❌ Current: Remove item just removes it (can't restore)
|
||||||
|
|
||||||
|
**Special Cases**
|
||||||
|
- ❌ No known glitch detection system
|
||||||
|
- ❌ No special handling for:
|
||||||
|
- Unconverted (UC) pets
|
||||||
|
- Invisible pets
|
||||||
|
- Dyeworks items
|
||||||
|
- Baby Body Paint warnings
|
||||||
|
- Faerie Uni dithering horn
|
||||||
|
- Body ID mismatches
|
||||||
|
- ❌ No glitch badges or warnings
|
||||||
|
|
||||||
|
**Appearance Customization**
|
||||||
|
- ❌ No pose locking (pinning to specific appearance version)
|
||||||
|
- ❌ No manual appearance ID selection
|
||||||
|
- ❌ No layer visibility controls
|
||||||
|
- ❌ No zone restriction customization
|
||||||
|
|
||||||
|
**Data Management**
|
||||||
|
- ❌ No modeling integration
|
||||||
|
- ❌ No contribution tracking
|
||||||
|
- ❌ No appearance version history
|
||||||
|
|
||||||
|
#### Support/Admin Features (Not Needed for MVP)
|
||||||
|
|
||||||
|
**Support Mode** (for staff users only):
|
||||||
|
- ❌ Item Support Drawer
|
||||||
|
- ❌ Appearance Layer Support Modal
|
||||||
|
- ❌ Pose Picker Support Mode
|
||||||
|
- ❌ All Item Layers Support Modal
|
||||||
|
- ❌ Debug features (appearance IDs, "Maybe Animated" badge)
|
||||||
|
- Note: These are staff-only and not required for general migration
|
||||||
|
|
||||||
|
## Technical Approach
|
||||||
|
|
||||||
|
### State Management Philosophy
|
||||||
|
|
||||||
|
**URL as Single Source of Truth**
|
||||||
|
- All outfit state encoded in URL params
|
||||||
|
- No JavaScript state management
|
||||||
|
- Every interaction generates a new URL via GET
|
||||||
|
- Browser back/forward work naturally
|
||||||
|
- Easy to bookmark/share
|
||||||
|
|
||||||
|
**Progressive Enhancement**
|
||||||
|
- Works without JavaScript (submit buttons, full page loads)
|
||||||
|
- Web Components enhance with auto-submit, hover effects
|
||||||
|
- Graceful degradation at every layer
|
||||||
|
|
||||||
|
### Rendering Strategy
|
||||||
|
|
||||||
|
**Server-Side Rendering**
|
||||||
|
- All HTML generated server-side
|
||||||
|
- No client-side templates
|
||||||
|
- Uses existing Rails helpers and partials
|
||||||
|
- Fast initial load, good for SEO
|
||||||
|
|
||||||
|
**Web Components for Interactivity**
|
||||||
|
- Lightweight custom elements for specific behaviors
|
||||||
|
- No framework overhead
|
||||||
|
- Easy to understand and maintain
|
||||||
|
- Examples: `<species-color-picker>`, `<outfit-viewer>`
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
User Action (click/submit)
|
||||||
|
↓
|
||||||
|
GET request with updated params
|
||||||
|
↓
|
||||||
|
Controller builds Outfit model
|
||||||
|
↓
|
||||||
|
Preloads all necessary data (SwfAssets, manifests)
|
||||||
|
↓
|
||||||
|
Renders full HTML with outfit_viewer
|
||||||
|
↓
|
||||||
|
Browser displays (instant if Turbo, full page otherwise)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Testing Strategy
|
||||||
|
|
||||||
|
**Helper specs** cover core logic:
|
||||||
|
- `outfit_items_by_zone` - extensively tested for all edge cases
|
||||||
|
- Multi-zone simplification
|
||||||
|
- Zone label disambiguation
|
||||||
|
- Incompatible item handling
|
||||||
|
- Sorting behavior
|
||||||
|
|
||||||
|
**Missing test coverage:**
|
||||||
|
- Controller specs
|
||||||
|
- Integration/system specs
|
||||||
|
- JavaScript web component tests
|
||||||
|
|
||||||
|
## Migration Challenges
|
||||||
|
|
||||||
|
### Data Model Gaps
|
||||||
|
|
||||||
|
**Current Issues:**
|
||||||
|
- Alt style support not implemented in `visible_layers`
|
||||||
|
- Outfit model designed around saved outfits (has `user_id`, `name`)
|
||||||
|
- Need to handle unsaved/anonymous outfits better
|
||||||
|
|
||||||
|
### Performance Concerns
|
||||||
|
|
||||||
|
**Full Page Loads**
|
||||||
|
- Every species/color/item change triggers new page load
|
||||||
|
- Could use Turbo Frames to update only changed sections
|
||||||
|
- Need to measure actual performance impact
|
||||||
|
|
||||||
|
**Asset Preloading**
|
||||||
|
- Currently preloads all visible layer manifests ([outfits_controller.rb:112](app/controllers/outfits_controller.rb#L112))
|
||||||
|
- Good for parallelization, but loads data that might not be needed
|
||||||
|
- Could be more selective
|
||||||
|
|
||||||
|
### User Experience Parity
|
||||||
|
|
||||||
|
**Wardrobe 2020 Features to Match:**
|
||||||
|
- Real-time search with instant results
|
||||||
|
- Drag-and-drop item management
|
||||||
|
- Visual zone conflict indicators
|
||||||
|
- Item info popovers
|
||||||
|
- "Try on all these items" bulk add
|
||||||
|
- Sharing outfits with generated images
|
||||||
|
|
||||||
|
### Migration Path Unknowns
|
||||||
|
|
||||||
|
**Big Questions:**
|
||||||
|
1. Can we achieve acceptable UX without heavy JavaScript?
|
||||||
|
2. Is Turbo sufficient, or do we need more React-like patterns?
|
||||||
|
3. How to handle the transition period (two wardrobes)?
|
||||||
|
4. What to do with existing saved outfits (data model changes)?
|
||||||
|
5. How to migrate user workflows (muscle memory, bookmarks)?
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
**Recently Completed:**
|
||||||
|
- ✅ Basic item search functionality (November 2025)
|
||||||
|
- Text search with auto-filtering by pet compatibility
|
||||||
|
- Pagination with 30 items per page
|
||||||
|
- Add/remove items with state preservation
|
||||||
|
- Clean URL-based state management with all search state scoped under `q[...]`
|
||||||
|
|
||||||
|
### Phase 1: Core Functionality (MVP)
|
||||||
|
|
||||||
|
**Goal:** Basic usable wardrobe that can compete with essential features.
|
||||||
|
|
||||||
|
1. **Item Search & Addition** 🟢 Complete (basic), 🟡 Enhancements pending
|
||||||
|
- [x] Search input field in sidebar
|
||||||
|
- [x] Basic text search (query Items API)
|
||||||
|
- [x] Paginated results display (30 per page)
|
||||||
|
- [x] Button to wear items (Add button on search results)
|
||||||
|
- [x] Add items to outfit via URL params
|
||||||
|
- [x] Empty state message
|
||||||
|
- [x] Auto-filter items to current pet compatibility
|
||||||
|
- [x] Back to outfit button to exit search
|
||||||
|
- [x] Search state scoped under `q[...]` params
|
||||||
|
- Optional enhancements:
|
||||||
|
- [ ] Inline search syntax (`is:nc`, `fits:blue-acara`)
|
||||||
|
- [ ] Autosuggest/autocomplete
|
||||||
|
- [ ] Filter chips display
|
||||||
|
- [ ] Advanced filter UI (NC/NP/PB toggles, zone selector)
|
||||||
|
- [ ] Keyboard navigation (arrows, escape, enter)
|
||||||
|
|
||||||
|
2. **Outfit Saving/Loading** 🔴 Critical
|
||||||
|
- [ ] Editable outfit name field
|
||||||
|
- [ ] Save button (for logged-in users)
|
||||||
|
- [ ] Auto-save on change (with debounce)
|
||||||
|
- [ ] "Saving..." / "Saved" indicator
|
||||||
|
- [ ] Route: `GET /outfits/:id/v2` to load saved outfit
|
||||||
|
- [ ] Owner-only editing enforcement
|
||||||
|
- [ ] Handle unsaved outfits gracefully
|
||||||
|
- Optional enhancements:
|
||||||
|
- [ ] Navigation blocking for unsaved changes
|
||||||
|
- [ ] Outfit menu (Delete, Edit a Copy)
|
||||||
|
|
||||||
|
3. **Pose Selection** 🟡 Important
|
||||||
|
- [ ] Pose picker UI (emotion grid)
|
||||||
|
- [ ] Visual pose thumbnails (tiny pet renders)
|
||||||
|
- [ ] Update pet_state via URL params
|
||||||
|
- [ ] Handle missing/invalid poses gracefully
|
||||||
|
- [ ] Add `pose` param to URL state
|
||||||
|
- Optional enhancements:
|
||||||
|
- [ ] Tabbed interface (Expressions / Styles)
|
||||||
|
- [ ] Pose availability indicators (gray out unavailable)
|
||||||
|
- [ ] Auto-recovery when pose becomes invalid
|
||||||
|
|
||||||
|
4. **Alt Styles Support** 🟡 Important
|
||||||
|
- [ ] Alt styles picker (Styles tab in pose picker?)
|
||||||
|
- [ ] Visual thumbnails for each style
|
||||||
|
- [ ] Add `style` param to URL state
|
||||||
|
- [ ] Update `Outfit#visible_layers` to handle alt styles
|
||||||
|
- [ ] "Default" option to return to normal
|
||||||
|
- Optional enhancements:
|
||||||
|
- [ ] Link to Rainbow Pool Styles info
|
||||||
|
|
||||||
|
### Phase 2: Polish & UX Improvements
|
||||||
|
|
||||||
|
**Goal:** Match quality and usability of Wardrobe 2020.
|
||||||
|
|
||||||
|
5. **Improved Item Display**
|
||||||
|
- [ ] Wear/unwear toggle (click item to toggle)
|
||||||
|
- [ ] Item info button (link to item page)
|
||||||
|
- [ ] Zone badges on items
|
||||||
|
- [ ] Smooth animations on removal (fade + collapse)
|
||||||
|
- [ ] Better incompatible items messaging
|
||||||
|
- [ ] Tooltips explaining incompatibility
|
||||||
|
|
||||||
|
6. **Preview Controls**
|
||||||
|
- [ ] Overlay controls (auto-hide on desktop, always visible on touch)
|
||||||
|
- [ ] Play/Pause animation button
|
||||||
|
- [ ] Download outfit as PNG
|
||||||
|
- [ ] Copy link to clipboard (with confirmation)
|
||||||
|
- [ ] Settings dropdown (hi-res mode, use archive)
|
||||||
|
- [ ] HTML5 conversion badge
|
||||||
|
- Optional:
|
||||||
|
- [ ] Known glitches badge
|
||||||
|
- [ ] Right-click context menu
|
||||||
|
|
||||||
|
7. **Loading & Error States**
|
||||||
|
- [ ] Loading spinners with delay
|
||||||
|
- [ ] Skeleton screens for items
|
||||||
|
- [ ] Smooth transitions (fade in/out)
|
||||||
|
- [ ] Toast notifications for errors
|
||||||
|
- [ ] Network error recovery
|
||||||
|
- [ ] Graceful degradation
|
||||||
|
|
||||||
|
8. **Mobile Optimization**
|
||||||
|
- [ ] Large touch targets
|
||||||
|
- [ ] Always-visible controls on touch devices
|
||||||
|
- [ ] Fix species/color picker on touch (no hover)
|
||||||
|
- [ ] Optimized layout for small screens
|
||||||
|
- [ ] Test swipe gestures
|
||||||
|
|
||||||
|
9. **Keyboard Navigation & Accessibility**
|
||||||
|
- [ ] Comprehensive keyboard shortcuts (match Wardrobe 2020)
|
||||||
|
- [ ] ARIA labels on all interactive elements
|
||||||
|
- [ ] Semantic heading hierarchy
|
||||||
|
- [ ] Skip links between sections
|
||||||
|
- [ ] High color contrast
|
||||||
|
- [ ] Screen reader testing
|
||||||
|
|
||||||
|
### Phase 3: Advanced Features
|
||||||
|
|
||||||
|
**Goal:** Feature parity with Wardrobe 2020 where valuable.
|
||||||
|
|
||||||
|
10. **User Features** (if keeping)
|
||||||
|
- [ ] User authentication integration
|
||||||
|
- [ ] Closet integration
|
||||||
|
- [ ] "You own this" badges
|
||||||
|
- [ ] "You want this" badges
|
||||||
|
- [ ] Filter search by owned/wanted items
|
||||||
|
- [ ] Shopping list link (Items Sources page)
|
||||||
|
|
||||||
|
11. **Conflict Detection & Management**
|
||||||
|
- [ ] Automatic zone conflict resolution
|
||||||
|
- [ ] Closet panel for conflicted items
|
||||||
|
- [ ] Smart item restoration when unwearing
|
||||||
|
- [ ] Visual conflict indicators
|
||||||
|
|
||||||
|
12. **Pet Loading**
|
||||||
|
- [ ] "Load my pet" input field
|
||||||
|
- [ ] Integration with modeling system
|
||||||
|
- [ ] Handle modeling errors gracefully
|
||||||
|
- [ ] Redirect to wardrobe with loaded pet
|
||||||
|
|
||||||
|
13. **Search Enhancements** (if needed)
|
||||||
|
- [ ] Advanced search dropdown
|
||||||
|
- [ ] Preload adjacent pages
|
||||||
|
- [ ] NC Styles intelligent hints
|
||||||
|
- [ ] Item restoration logic
|
||||||
|
|
||||||
|
14. **URL Enhancements**
|
||||||
|
- [ ] Add missing params (pose, style, state, closet[])
|
||||||
|
- [ ] Browser back/forward support (Turbo handles this?)
|
||||||
|
- [ ] Legacy URL format support (#params)
|
||||||
|
- [ ] Clean URL generation
|
||||||
|
|
||||||
|
### Phase 4: Performance & Optimization
|
||||||
|
|
||||||
|
**Goal:** Fast, smooth experience comparable to React version.
|
||||||
|
|
||||||
|
15. **Performance Improvements**
|
||||||
|
- [ ] Evaluate Turbo Frames for partial updates
|
||||||
|
- [ ] Measure page load times
|
||||||
|
- [ ] Image preloading strategies
|
||||||
|
- [ ] Optimize database queries (N+1, etc.)
|
||||||
|
- [ ] Consider caching strategies
|
||||||
|
- [ ] Monitor real-world performance
|
||||||
|
|
||||||
|
16. **Visual Polish**
|
||||||
|
- [ ] Design refinement (match DTI aesthetic)
|
||||||
|
- [ ] Dark mode support
|
||||||
|
- [ ] Smooth animations throughout
|
||||||
|
- [ ] Responsive typography
|
||||||
|
- [ ] Consistent spacing/layout
|
||||||
|
|
||||||
|
### Phase 5: Migration & Rollout
|
||||||
|
|
||||||
|
**Goal:** Transition users from Wardrobe 2020 to Wardrobe V2.
|
||||||
|
|
||||||
|
17. **Migration Preparation**
|
||||||
|
- [ ] Feature flag system (A/B test)
|
||||||
|
- [ ] User feedback collection mechanism
|
||||||
|
- [ ] Performance monitoring
|
||||||
|
- [ ] Bug tracking and triage
|
||||||
|
- [ ] Documentation for users
|
||||||
|
|
||||||
|
18. **Gradual Rollout**
|
||||||
|
- [ ] Internal testing (staff only)
|
||||||
|
- [ ] Beta testing (opt-in users)
|
||||||
|
- [ ] Gradual percentage rollout
|
||||||
|
- [ ] Monitor error rates and feedback
|
||||||
|
- [ ] Make default for new users
|
||||||
|
- [ ] Eventually deprecate old wardrobe
|
||||||
|
|
||||||
|
19. **Deprecation Cleanup**
|
||||||
|
- [ ] Remove wardrobe-2020 JavaScript code
|
||||||
|
- [ ] Update Impress 2020 dependencies doc
|
||||||
|
- [ ] Remove GraphQL queries (if no longer needed)
|
||||||
|
- [ ] Simplify codebase
|
||||||
|
- [ ] Update documentation
|
||||||
|
|
||||||
|
### Deferred / Maybe Never
|
||||||
|
|
||||||
|
**Features that may not be worth implementing:**
|
||||||
|
|
||||||
|
- ❓ Support mode features (can keep using old wardrobe for this)
|
||||||
|
- ❓ Unconverted pet support (being phased out in favor of Alt Styles)
|
||||||
|
- ❓ Known glitches system (complex, low value)
|
||||||
|
- ❓ Appearance version pinning (niche feature)
|
||||||
|
- ❓ Manual appearance ID selection (staff only)
|
||||||
|
- ❓ Layer visibility controls (complexity vs value)
|
||||||
|
- ❓ Low FPS detection and auto-pause (nice-to-have)
|
||||||
|
- ❓ All the React.memo micro-optimizations (Rails is different)
|
||||||
|
|
||||||
|
### Success Criteria for Each Phase
|
||||||
|
|
||||||
|
**Phase 1 (MVP):**
|
||||||
|
- ✅ Can search for and add items
|
||||||
|
- ✅ Can remove items
|
||||||
|
- ✅ Can change species/color/pose
|
||||||
|
- ✅ Can save and load outfits
|
||||||
|
- ✅ Can use alt styles
|
||||||
|
- ✅ Works without JavaScript (progressive enhancement)
|
||||||
|
|
||||||
|
**Phase 2 (Polish):**
|
||||||
|
- ✅ Feels responsive and smooth
|
||||||
|
- ✅ Works well on mobile
|
||||||
|
- ✅ Accessible to keyboard and screen reader users
|
||||||
|
- ✅ Handles errors gracefully
|
||||||
|
- ✅ Loading states don't feel jarring
|
||||||
|
|
||||||
|
**Phase 3 (Advanced):**
|
||||||
|
- ✅ Feature parity with Wardrobe 2020 for common workflows
|
||||||
|
- ✅ Closet integration works (if keeping)
|
||||||
|
- ✅ Conflict resolution feels natural
|
||||||
|
- ✅ Pet loading works reliably
|
||||||
|
|
||||||
|
**Phase 4 (Performance):**
|
||||||
|
- ✅ Page loads in < 1 second
|
||||||
|
- ✅ Interactions feel instant (< 100ms perceived)
|
||||||
|
- ✅ No janky animations
|
||||||
|
- ✅ Works well on slower connections
|
||||||
|
|
||||||
|
**Phase 5 (Migration):**
|
||||||
|
- ✅ User satisfaction meets or exceeds old wardrobe
|
||||||
|
- ✅ Error rates acceptable (< 1% of interactions)
|
||||||
|
- ✅ Can safely deprecate old wardrobe
|
||||||
|
- ✅ Impress 2020 dependencies reduced/eliminated
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
**Architecture Decisions:**
|
||||||
|
- Should we commit fully to Rails/Turbo, or is some React necessary?
|
||||||
|
- Can we get away with just Web Components, or need a framework?
|
||||||
|
- Is the URL-as-state approach sustainable as complexity grows?
|
||||||
|
|
||||||
|
**Feature Parity:**
|
||||||
|
- Which wardrobe-2020 features are actually essential?
|
||||||
|
- What can we simplify or eliminate?
|
||||||
|
- Are there features users don't use that we can drop?
|
||||||
|
|
||||||
|
**Migration Logistics:**
|
||||||
|
- Do we maintain both wardrobes during transition?
|
||||||
|
- How to handle user preferences (which wardrobe to use)?
|
||||||
|
- What's the deprecation timeline for Impress 2020?
|
||||||
|
|
||||||
|
**Data Model:**
|
||||||
|
- Should Outfit model change to better support unsaved/anonymous outfits?
|
||||||
|
- How to handle alt styles in visible_layers?
|
||||||
|
- Any schema changes needed?
|
||||||
|
|
||||||
|
## Appendix: Wardrobe 2020 Complete Feature Reference
|
||||||
|
|
||||||
|
This section documents ALL features in the React-based Wardrobe 2020 for reference during migration.
|
||||||
|
|
||||||
|
### UI Layout
|
||||||
|
|
||||||
|
**Main Structure:**
|
||||||
|
- Split-screen: Preview (left/top) + Items/Search panel (right/bottom)
|
||||||
|
- Responsive: Different layouts for mobile vs desktop
|
||||||
|
- Search footer (desktop only, behind feature flag)
|
||||||
|
|
||||||
|
### Pet Customization
|
||||||
|
|
||||||
|
**Species & Color Picker:**
|
||||||
|
- Species dropdown
|
||||||
|
- Color dropdown
|
||||||
|
- Real-time validation (red border for invalid combos)
|
||||||
|
- Smart fallback to basic colors
|
||||||
|
- Loading states with placeholders
|
||||||
|
|
||||||
|
**Pose Picker:**
|
||||||
|
- Tabbed interface: "Expressions" and "Styles"
|
||||||
|
- Expression tab: 3×2 grid (Happy/Sad/Sick × Masc/Fem)
|
||||||
|
- Visual pose preview thumbnails
|
||||||
|
- Unconverted (UC) option with warning
|
||||||
|
- Pose availability indicators (grayed out + "?")
|
||||||
|
- Auto-recovery to valid pose
|
||||||
|
- Full keyboard/screen reader support
|
||||||
|
|
||||||
|
**Alt Styles (Styles Tab):**
|
||||||
|
- Grid of available alt styles for species
|
||||||
|
- "Default" option to return to normal
|
||||||
|
- Visual thumbnails
|
||||||
|
- Link to Rainbow Pool Styles page
|
||||||
|
|
||||||
|
### Item Management
|
||||||
|
|
||||||
|
**Items Panel:**
|
||||||
|
- Editable outfit name (click to rename)
|
||||||
|
- Items grouped by zone (Hat, Jacket, etc.)
|
||||||
|
- Zone conflict display (multiple items per zone)
|
||||||
|
- Zone label deduplication (adds IDs)
|
||||||
|
- Incompatible items section with tooltip
|
||||||
|
- Per-item actions:
|
||||||
|
- Remove (X icon)
|
||||||
|
- Info (opens item page)
|
||||||
|
- Support drawer (staff only)
|
||||||
|
- Wear/unwear toggle (click/space to toggle)
|
||||||
|
- Radio button behavior for zone conflicts
|
||||||
|
- Keyboard navigation (space, tab)
|
||||||
|
- Smooth animations (fade + collapse)
|
||||||
|
|
||||||
|
**Item Display:**
|
||||||
|
- Thumbnail image
|
||||||
|
- Item name
|
||||||
|
- Badge system:
|
||||||
|
- NC/NP/PB (item type)
|
||||||
|
- Zone badges (occupied zones)
|
||||||
|
- Restricted zone badges
|
||||||
|
- "You own this" (logged in)
|
||||||
|
- "You want this" (logged in)
|
||||||
|
- "Maybe animated" (support only)
|
||||||
|
|
||||||
|
### Search
|
||||||
|
|
||||||
|
**Search Toolbar:**
|
||||||
|
- Free text input
|
||||||
|
- Autosuggest dropdown
|
||||||
|
- Advanced search dropdown (chevron)
|
||||||
|
- Active filter chips
|
||||||
|
- Clear button (X)
|
||||||
|
|
||||||
|
**Search Filters:**
|
||||||
|
- Item type: NC, NP, PB
|
||||||
|
- Zone filter (by body zone)
|
||||||
|
- Ownership: items you own, items you want (logged in)
|
||||||
|
|
||||||
|
**Search Results:**
|
||||||
|
- Paginated (30 per page)
|
||||||
|
- Pagination toolbar
|
||||||
|
- Preloads adjacent pages
|
||||||
|
- Visual checkboxes (wear/unwear)
|
||||||
|
- Item restoration logic
|
||||||
|
- Keyboard navigation:
|
||||||
|
- Arrow Up/Down between results
|
||||||
|
- Arrow Up from first → search box
|
||||||
|
- Escape → search box
|
||||||
|
- Enter to toggle
|
||||||
|
- NC Styles intelligent hint
|
||||||
|
- Empty state message
|
||||||
|
|
||||||
|
### Outfit Preview
|
||||||
|
|
||||||
|
**Preview Display:**
|
||||||
|
- 600×600px canvas
|
||||||
|
- Layered rendering (proper z-index)
|
||||||
|
- Loading spinner (with delay)
|
||||||
|
- Cached thumbnail placeholder
|
||||||
|
- HTML5 Canvas animations
|
||||||
|
- SVG hi-res mode (optional)
|
||||||
|
- Performance monitoring (auto-pause on low FPS)
|
||||||
|
- Error handling (fallback to static)
|
||||||
|
- Smooth transitions
|
||||||
|
|
||||||
|
**Overlay Controls:**
|
||||||
|
- Auto-hide on desktop (hover/focus to show)
|
||||||
|
- Always visible on touch devices
|
||||||
|
- Focus lock on touch (tap to lock)
|
||||||
|
|
||||||
|
**Control Buttons:**
|
||||||
|
- Back (to homepage/Your Outfits)
|
||||||
|
- Play/Pause (remembers in localStorage)
|
||||||
|
- Download PNG (pre-generates on hover)
|
||||||
|
- Copy link (shows "Copied!" confirmation)
|
||||||
|
- Settings popover:
|
||||||
|
- Hi-res mode toggle
|
||||||
|
- Use DTI's archive toggle
|
||||||
|
|
||||||
|
**Info Badges:**
|
||||||
|
- HTML5 conversion status:
|
||||||
|
- Green checkmark (fully converted)
|
||||||
|
- Gray/warning (not converted)
|
||||||
|
- Lists unconverted items
|
||||||
|
- Known glitches badge:
|
||||||
|
- Lists specific issues
|
||||||
|
- Covers: UC compat, Dyeworks, Baby Body Paint, Invisible, etc.
|
||||||
|
|
||||||
|
**Context Menu (Right-click):**
|
||||||
|
- Download option
|
||||||
|
- Layers info modal (SWF/PNG/SVG links)
|
||||||
|
|
||||||
|
### Outfit Saving
|
||||||
|
|
||||||
|
**Save Functionality:**
|
||||||
|
- Auto-save (debounced)
|
||||||
|
- Save button for new outfits
|
||||||
|
- Save indicator (Saving... / Saved / error)
|
||||||
|
- Version tracking
|
||||||
|
- Navigation blocking (unsaved changes)
|
||||||
|
- Owner-only editing
|
||||||
|
|
||||||
|
**Outfit Menu:**
|
||||||
|
- Edit a copy (new tab)
|
||||||
|
- Rename (inline editing)
|
||||||
|
- Delete (with confirmation)
|
||||||
|
- Shopping list (Items Sources page)
|
||||||
|
|
||||||
|
### URL State
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- `outfit` - Outfit ID
|
||||||
|
- `name` - Outfit name
|
||||||
|
- `species` - Species ID
|
||||||
|
- `color` - Color ID
|
||||||
|
- `pose` - Pose string (HAPPY_FEM, etc.)
|
||||||
|
- `style` - Alt Style ID
|
||||||
|
- `state` - Appearance ID (version pinning)
|
||||||
|
- `objects[]` - Worn item IDs
|
||||||
|
- `closet[]` - Closeted item IDs
|
||||||
|
|
||||||
|
**Behavior:**
|
||||||
|
- Real-time URL updates
|
||||||
|
- Browser back/forward support
|
||||||
|
- Deep linking
|
||||||
|
- Legacy format support (`#params`)
|
||||||
|
- Path routing (`/outfits/:id` or `/outfits/new`)
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
**Search Panel:**
|
||||||
|
- Escape: Clear/close
|
||||||
|
- Enter: Accept suggestion
|
||||||
|
- Arrow Down (from search): Focus first result
|
||||||
|
- Arrow Up/Down: Navigate results
|
||||||
|
- Arrow Up (from first): Back to search
|
||||||
|
- Backspace (at start): Clear filters
|
||||||
|
- Space: Toggle item
|
||||||
|
|
||||||
|
**Items Panel:**
|
||||||
|
- Space: Toggle wear/unwear
|
||||||
|
- Tab: Navigate between items
|
||||||
|
|
||||||
|
**Pose Picker:**
|
||||||
|
- Tab: Navigate poses
|
||||||
|
- Enter/Space: Select
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
|
||||||
|
**Screen Readers:**
|
||||||
|
- ARIA labels on all controls
|
||||||
|
- VisuallyHidden checkboxes/radios
|
||||||
|
- Semantic HTML (headings, landmarks)
|
||||||
|
- Landmark regions
|
||||||
|
|
||||||
|
**Keyboard:**
|
||||||
|
- Full keyboard control
|
||||||
|
- Logical focus order
|
||||||
|
- Visible focus indicators
|
||||||
|
- Skip links
|
||||||
|
|
||||||
|
**Visual:**
|
||||||
|
- High color contrast
|
||||||
|
- Dark mode support
|
||||||
|
- Clear focus states
|
||||||
|
- Icon + text labels
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
**Optimizations:**
|
||||||
|
- React.memo on heavy components
|
||||||
|
- Object caching (prevent re-renders)
|
||||||
|
- GraphQL query caching
|
||||||
|
- Image preloading
|
||||||
|
- LRU caches (movie clips, etc.)
|
||||||
|
- Pagination preloading
|
||||||
|
|
||||||
|
**Error Handling:**
|
||||||
|
- Error boundaries
|
||||||
|
- Network error recovery
|
||||||
|
- Animation fallbacks
|
||||||
|
- Low FPS detection + auto-pause
|
||||||
|
- Toast notifications
|
||||||
|
|
||||||
|
**Loading:**
|
||||||
|
- Skeleton screens
|
||||||
|
- Delayed spinners
|
||||||
|
- Incremental loading
|
||||||
|
- Non-blocking overlays
|
||||||
|
|
||||||
|
### Mobile/Responsive
|
||||||
|
|
||||||
|
- Touch-friendly targets
|
||||||
|
- Vertical stacking on mobile
|
||||||
|
- Adapted controls for small screens
|
||||||
|
- Always-visible buttons on touch
|
||||||
|
- No hover-only interactions
|
||||||
|
|
||||||
|
### Special Cases
|
||||||
|
|
||||||
|
**Known Glitches System:**
|
||||||
|
- UC/Invisible compatibility
|
||||||
|
- OFFICIAL_SWF_IS_INCORRECT
|
||||||
|
- OFFICIAL_MOVIE_IS_INCORRECT
|
||||||
|
- OFFICIAL_SVG_IS_INCORRECT (hi-res)
|
||||||
|
- DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN
|
||||||
|
- OFFICIAL_BODY_ID_IS_INCORRECT
|
||||||
|
- Dyeworks unconverted warnings
|
||||||
|
- Baby Body Paint warnings
|
||||||
|
- Invisible pet blanket warning
|
||||||
|
- Faerie Uni dithering horn
|
||||||
|
|
||||||
|
**Special Pet Types:**
|
||||||
|
- Unconverted (UC) pets
|
||||||
|
- Invisible pets
|
||||||
|
- Alt Style pets
|
||||||
|
- Dithering pets
|
||||||
|
|
||||||
|
### Support/Admin Features
|
||||||
|
|
||||||
|
**Support Mode (Staff Only):**
|
||||||
|
- Item Support Drawer
|
||||||
|
- Appearance Layer Support Modal
|
||||||
|
- Pose Picker Support Mode
|
||||||
|
- All Item Layers Support Modal
|
||||||
|
- Debug info (appearance IDs, localhost)
|
||||||
|
- "Maybe Animated" badge
|
||||||
|
- Extra logging
|
||||||
|
|
||||||
|
### Data Features
|
||||||
|
|
||||||
|
**Conflict Detection:**
|
||||||
|
- Auto zone conflict resolution
|
||||||
|
- Smart item restoration on unwear
|
||||||
|
- Zone restriction handling
|
||||||
|
|
||||||
|
**Appearance Data:**
|
||||||
|
- Body ID compatibility system
|
||||||
|
- Alt Style support
|
||||||
|
- Pose locking (via appearanceId)
|
||||||
|
- Unconverted preservation
|
||||||
|
|
||||||
|
**User Data (Logged In):**
|
||||||
|
- Ownership tracking
|
||||||
|
- Wishlist tracking
|
||||||
|
- Closet filtering
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
**Related Documentation:**
|
||||||
|
- [Impress 2020 Dependencies](./impress-2020-dependencies.md) - What still uses the GraphQL API
|
||||||
|
- [Customization Architecture](./customization-architecture.md) - Data model deep dive
|
||||||
|
|
||||||
|
**Wardrobe V2 Files:**
|
||||||
|
- Controller: [app/controllers/outfits_controller.rb](../app/controllers/outfits_controller.rb)
|
||||||
|
- View: [app/views/outfits/new_v2.html.haml](../app/views/outfits/new_v2.html.haml)
|
||||||
|
- Helpers: [app/helpers/outfits_helper.rb](../app/helpers/outfits_helper.rb)
|
||||||
|
- Tests: [spec/helpers/outfits_helper_spec.rb](../spec/helpers/outfits_helper_spec.rb)
|
||||||
|
- Styles: [app/assets/stylesheets/outfits/new_v2.css](../app/assets/stylesheets/outfits/new_v2.css)
|
||||||
|
- JavaScript: [app/assets/javascripts/outfits/new_v2.js](../app/assets/javascripts/outfits/new_v2.js)
|
||||||
|
- Web Components:
|
||||||
|
- [app/assets/javascripts/species-color-picker.js](../app/assets/javascripts/species-color-picker.js)
|
||||||
|
- [app/assets/javascripts/outfit-viewer.js](../app/assets/javascripts/outfit-viewer.js)
|
||||||
|
|
||||||
|
**Wardrobe 2020 Files (React):**
|
||||||
|
- Main directory: [app/javascript/wardrobe-2020/](../app/javascript/wardrobe-2020/)
|
||||||
|
- Main page: [WardrobePage/index.js](../app/javascript/wardrobe-2020/WardrobePage/index.js)
|
||||||
|
- State: [useOutfitState.js](../app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js)
|
||||||
|
- Search: [SearchToolbar.js](../app/javascript/wardrobe-2020/WardrobePage/SearchToolbar.js)
|
||||||
|
- Items: [ItemsPanel.js](../app/javascript/wardrobe-2020/WardrobePage/ItemsPanel.js)
|
||||||
|
- Preview: [OutfitPreview.js](../app/javascript/wardrobe-2020/components/OutfitPreview.js)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Status:** Living document - update as migration progresses
|
||||||
|
**Last Updated:** 2025-11-03
|
||||||
|
**Current Branch:** `feature/wardrobe-v2`
|
||||||
310
spec/helpers/outfits_helper_spec.rb
Normal file
310
spec/helpers/outfits_helper_spec.rb
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
require_relative '../rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe OutfitsHelper, type: :helper do
|
||||||
|
fixtures :zones, :colors, :species, :pet_types
|
||||||
|
|
||||||
|
# Use the Blue Acara's body_id throughout tests
|
||||||
|
let(:body_id) { pet_types(:blue_acara).body_id }
|
||||||
|
|
||||||
|
# Helper to create a test outfit with a pet type
|
||||||
|
# Biology assets are just setup noise - we only care about pet_type.body_id
|
||||||
|
def create_test_outfit
|
||||||
|
pet_type = pet_types(:blue_acara)
|
||||||
|
|
||||||
|
# PetState requires at least one biology asset (validation requirement)
|
||||||
|
bio_asset = SwfAsset.create!(
|
||||||
|
type: "biology",
|
||||||
|
remote_id: (@bio_remote_id = (@bio_remote_id || 1000) + 1),
|
||||||
|
url: "https://images.neopets.example/bio_#{@bio_remote_id}.swf",
|
||||||
|
zone: zones(:body),
|
||||||
|
zones_restrict: "",
|
||||||
|
body_id: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
pet_state = PetState.create!(
|
||||||
|
pet_type: pet_type,
|
||||||
|
pose: "HAPPY_MASC",
|
||||||
|
swf_assets: [bio_asset],
|
||||||
|
swf_asset_ids: [bio_asset.id]
|
||||||
|
)
|
||||||
|
|
||||||
|
Outfit.create!(pet_state: pet_state)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to create SwfAssets for items (matches pattern from item_spec.rb)
|
||||||
|
def build_item_asset(zone, body_id:)
|
||||||
|
@item_remote_id = (@item_remote_id || 0) + 1
|
||||||
|
SwfAsset.create!(
|
||||||
|
type: "object",
|
||||||
|
remote_id: @item_remote_id,
|
||||||
|
url: "https://images.neopets.example/item_#{@item_remote_id}.swf",
|
||||||
|
zone: zone,
|
||||||
|
zones_restrict: "",
|
||||||
|
body_id: body_id
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Helper to create an item with zones
|
||||||
|
def create_item(name, zones_and_bodies)
|
||||||
|
item = Item.create!(
|
||||||
|
name: name,
|
||||||
|
description: "",
|
||||||
|
thumbnail_url: "https://images.neopets.example/#{name.parameterize}.gif",
|
||||||
|
rarity: "Common",
|
||||||
|
price: 100,
|
||||||
|
zones_restrict: "0" * 52
|
||||||
|
)
|
||||||
|
|
||||||
|
zones_and_bodies.each do |zone, body_id|
|
||||||
|
item.swf_assets << build_item_asset(zone, body_id: body_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
item
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#outfit_items_by_zone' do
|
||||||
|
context 'with nil pet_type' do
|
||||||
|
it 'returns empty array' do
|
||||||
|
# Create an outfit without a pet_state (pet_type will be nil)
|
||||||
|
outfit = Outfit.new
|
||||||
|
# Allow the delegation to fail gracefully
|
||||||
|
allow(outfit).to receive(:pet_type).and_return(nil)
|
||||||
|
|
||||||
|
result = helper.outfit_items_by_zone(outfit)
|
||||||
|
expect(result).to eq([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with empty outfit' do
|
||||||
|
it 'returns empty array' do
|
||||||
|
outfit = create_test_outfit
|
||||||
|
result = helper.outfit_items_by_zone(outfit)
|
||||||
|
expect(result).to eq([])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with single-zone items' do
|
||||||
|
let(:outfit) { create_test_outfit }
|
||||||
|
let!(:hat_item) { create_item("Blue Hat", [[zones(:hat), body_id]]) }
|
||||||
|
let!(:jacket_item) { create_item("Red Jacket", [[zones(:jacket), body_id]]) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
outfit.worn_items << hat_item
|
||||||
|
outfit.worn_items << jacket_item
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'groups items by zone' do
|
||||||
|
result = helper.outfit_items_by_zone(outfit)
|
||||||
|
|
||||||
|
expect(result.length).to eq(2)
|
||||||
|
zone_labels = result.map { |g| g[:zone_label] }
|
||||||
|
expect(zone_labels).to contain_exactly("Hat", "Jacket")
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sorts zones alphabetically' do
|
||||||
|
result = helper.outfit_items_by_zone(outfit)
|
||||||
|
|
||||||
|
zone_labels = result.map { |g| g[:zone_label] }
|
||||||
|
expect(zone_labels).to eq(["Hat", "Jacket"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'includes items in their respective zones' do
|
||||||
|
result = helper.outfit_items_by_zone(outfit)
|
||||||
|
|
||||||
|
hat_group = result.find { |g| g[:zone_label] == "Hat" }
|
||||||
|
jacket_group = result.find { |g| g[:zone_label] == "Jacket" }
|
||||||
|
|
||||||
|
expect(hat_group[:items]).to eq([hat_item])
|
||||||
|
expect(jacket_group[:items]).to eq([jacket_item])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with multiple items in same zone' do
|
||||||
|
let(:outfit) { create_test_outfit }
|
||||||
|
let!(:hat1) { create_item("Awesome Hat", [[zones(:hat), body_id]]) }
|
||||||
|
let!(:hat2) { create_item("Cool Hat", [[zones(:hat), body_id]]) }
|
||||||
|
let!(:hat3) { create_item("Blue Hat", [[zones(:hat), body_id]]) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
outfit.worn_items << hat1
|
||||||
|
outfit.worn_items << hat2
|
||||||
|
outfit.worn_items << hat3
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sorts items alphabetically within zone' do
|
||||||
|
result = helper.outfit_items_by_zone(outfit)
|
||||||
|
|
||||||
|
hat_group = result.find { |g| g[:zone_label] == "Hat" }
|
||||||
|
item_names = hat_group[:items].map(&:name)
|
||||||
|
|
||||||
|
expect(item_names).to eq(["Awesome Hat", "Blue Hat", "Cool Hat"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with multi-zone item (no conflicts)' do
|
||||||
|
let(:outfit) { create_test_outfit }
|
||||||
|
let!(:bow_tie) do
|
||||||
|
create_item("Bow Tie", [
|
||||||
|
[zones(:collar), body_id],
|
||||||
|
[zones(:necklace), body_id],
|
||||||
|
[zones(:earrings), body_id]
|
||||||
|
])
|
||||||
|
end
|
||||||
|
|
||||||
|
before do
|
||||||
|
outfit.worn_items << bow_tie
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'shows item in only one zone (simplification)' do
|
||||||
|
result = helper.outfit_items_by_zone(outfit)
|
||||||
|
|
||||||
|
# Should show in Collar zone only (first alphabetically)
|
||||||
|
expect(result.length).to eq(1)
|
||||||
|
expect(result[0][:zone_label]).to eq("Collar")
|
||||||
|
expect(result[0][:items]).to eq([bow_tie])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with multi-zone simplification (item appears in conflict zone)' do
|
||||||
|
let(:outfit) { create_test_outfit }
|
||||||
|
let!(:multi_zone_item) do
|
||||||
|
create_item("Fancy Outfit", [
|
||||||
|
[zones(:jacket), body_id],
|
||||||
|
[zones(:collar), body_id]
|
||||||
|
])
|
||||||
|
end
|
||||||
|
let!(:collar_item) { create_item("Simple Collar", [[zones(:collar), body_id]]) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
outfit.worn_items << multi_zone_item
|
||||||
|
outfit.worn_items << collar_item
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'keeps conflict zone and hides redundant single-item zone' do
|
||||||
|
result = helper.outfit_items_by_zone(outfit)
|
||||||
|
|
||||||
|
zone_labels = result.map { |g| g[:zone_label] }
|
||||||
|
|
||||||
|
# Should show Collar (has conflict with 2 items)
|
||||||
|
# Should NOT show Jacket (redundant - item already in Collar)
|
||||||
|
expect(zone_labels).to eq(["Collar"])
|
||||||
|
|
||||||
|
collar_group = result.find { |g| g[:zone_label] == "Collar" }
|
||||||
|
item_names = collar_group[:items].map(&:name).sort
|
||||||
|
expect(item_names).to eq(["Fancy Outfit", "Simple Collar"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with zone label disambiguation' do
|
||||||
|
let(:outfit) { create_test_outfit }
|
||||||
|
|
||||||
|
# Create additional zones with duplicate labels for this test
|
||||||
|
let!(:markings_zone_a) do
|
||||||
|
Zone.create!(label: "Markings", depth: 100, plain_label: "markings_a", type_id: 2)
|
||||||
|
end
|
||||||
|
let!(:markings_zone_b) do
|
||||||
|
Zone.create!(label: "Markings", depth: 101, plain_label: "markings_b", type_id: 2)
|
||||||
|
end
|
||||||
|
|
||||||
|
let!(:item_zone_a) { create_item("Tattoo A", [[markings_zone_a, body_id]]) }
|
||||||
|
let!(:item_zone_b) { create_item("Tattoo B", [[markings_zone_b, body_id]]) }
|
||||||
|
let!(:item_zone_a_b) { create_item("Tattoo C", [[markings_zone_a, body_id]]) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
outfit.worn_items << item_zone_a
|
||||||
|
outfit.worn_items << item_zone_b
|
||||||
|
outfit.worn_items << item_zone_a_b
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'adds zone IDs to duplicate labels' do
|
||||||
|
result = helper.outfit_items_by_zone(outfit)
|
||||||
|
|
||||||
|
zone_labels = result.map { |g| g[:zone_label] }
|
||||||
|
|
||||||
|
# Both should have IDs appended since they share the label "Markings"
|
||||||
|
expect(zone_labels).to contain_exactly(
|
||||||
|
"Markings (##{markings_zone_a.id})",
|
||||||
|
"Markings (##{markings_zone_b.id})"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'groups items correctly by zone despite same label' do
|
||||||
|
result = helper.outfit_items_by_zone(outfit)
|
||||||
|
|
||||||
|
zone_a_group = result.find { |g| g[:zone_label] == "Markings (##{markings_zone_a.id})" }
|
||||||
|
zone_b_group = result.find { |g| g[:zone_label] == "Markings (##{markings_zone_b.id})" }
|
||||||
|
|
||||||
|
expect(zone_a_group[:items].map(&:name).sort).to eq(["Tattoo A", "Tattoo C"])
|
||||||
|
expect(zone_b_group[:items].map(&:name)).to eq(["Tattoo B"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with incompatible items' do
|
||||||
|
let(:outfit) { create_test_outfit }
|
||||||
|
let!(:compatible_item) { create_item("Fits Pet", [[zones(:hat), body_id]]) }
|
||||||
|
let!(:incompatible_item) { create_item("Wrong Body", [[zones(:jacket), 999]]) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
outfit.worn_items << compatible_item
|
||||||
|
outfit.worn_items << incompatible_item
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'separates incompatible items into their own section' do
|
||||||
|
result = helper.outfit_items_by_zone(outfit)
|
||||||
|
|
||||||
|
zone_labels = result.map { |g| g[:zone_label] }
|
||||||
|
expect(zone_labels).to contain_exactly("Hat", "Incompatible")
|
||||||
|
|
||||||
|
incompatible_group = result.find { |g| g[:zone_label] == "Incompatible" }
|
||||||
|
expect(incompatible_group[:items]).to eq([incompatible_item])
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'sorts incompatible items alphabetically' do
|
||||||
|
outfit.worn_items << create_item("Alpha Item", [[zones(:jacket), 999]])
|
||||||
|
outfit.worn_items << create_item("Zulu Item", [[zones(:jacket), 999]])
|
||||||
|
|
||||||
|
result = helper.outfit_items_by_zone(outfit)
|
||||||
|
incompatible_group = result.find { |g| g[:zone_label] == "Incompatible" }
|
||||||
|
|
||||||
|
item_names = incompatible_group[:items].map(&:name)
|
||||||
|
expect(item_names).to eq(["Alpha Item", "Wrong Body", "Zulu Item"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with complex multi-zone scenario' do
|
||||||
|
let(:outfit) { create_test_outfit }
|
||||||
|
let!(:bg1) { create_item("Forest Background", [[zones(:background), 0]]) }
|
||||||
|
let!(:bg2) { create_item("Beach Background", [[zones(:background), 0]]) }
|
||||||
|
let!(:multi_item) do
|
||||||
|
create_item("Wings and Hat", [
|
||||||
|
[zones(:wings), 0],
|
||||||
|
[zones(:hat), 0]
|
||||||
|
])
|
||||||
|
end
|
||||||
|
let!(:hat_item) { create_item("Simple Hat", [[zones(:hat), 0]]) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
outfit.worn_items << bg1
|
||||||
|
outfit.worn_items << bg2
|
||||||
|
outfit.worn_items << multi_item
|
||||||
|
outfit.worn_items << hat_item
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'correctly applies all sorting and grouping rules' do
|
||||||
|
result = helper.outfit_items_by_zone(outfit)
|
||||||
|
|
||||||
|
# Background: has conflict (2 items)
|
||||||
|
# Hat: has conflict (2 items, including multi-zone item)
|
||||||
|
# Wings: should be hidden (multi-zone item already in Hat conflict)
|
||||||
|
zone_labels = result.map { |g| g[:zone_label] }
|
||||||
|
expect(zone_labels).to eq(["Background", "Hat"])
|
||||||
|
|
||||||
|
bg_group = result.find { |g| g[:zone_label] == "Background" }
|
||||||
|
expect(bg_group[:items].map(&:name)).to eq(["Beach Background", "Forest Background"])
|
||||||
|
|
||||||
|
hat_group = result.find { |g| g[:zone_label] == "Hat" }
|
||||||
|
expect(hat_group[:items].map(&:name)).to eq(["Simple Hat", "Wings and Hat"])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
288
spec/models/outfit_spec.rb
Normal file
288
spec/models/outfit_spec.rb
Normal file
|
|
@ -0,0 +1,288 @@
|
||||||
|
require_relative '../rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Outfit do
|
||||||
|
fixtures :colors, :species, :zones
|
||||||
|
|
||||||
|
let(:blue) { colors(:blue) }
|
||||||
|
let(:acara) { species(:acara) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
PetType.destroy_all
|
||||||
|
@pet_type = PetType.create!(color: blue, species: acara, body_id: 1)
|
||||||
|
@pet_state = create_pet_state(@pet_type, "HAPPY_MASC")
|
||||||
|
@outfit = Outfit.new(pet_state: @pet_state)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_pet_state(pet_type, pose)
|
||||||
|
# Create a basic biology asset so pet state saves correctly
|
||||||
|
swf_asset = SwfAsset.create!(
|
||||||
|
type: "biology",
|
||||||
|
remote_id: (SwfAsset.maximum(:remote_id) || 0) + 1,
|
||||||
|
url: "https://images.neopets.example/biology.swf",
|
||||||
|
zone: zones(:body),
|
||||||
|
zones_restrict: "",
|
||||||
|
body_id: pet_type.body_id
|
||||||
|
)
|
||||||
|
PetState.create!(
|
||||||
|
pet_type: pet_type,
|
||||||
|
pose: pose,
|
||||||
|
swf_assets: [swf_asset],
|
||||||
|
swf_asset_ids: [swf_asset.id]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_item(name, zone, body_id: 1, zones_restrict: "")
|
||||||
|
item = Item.create!(
|
||||||
|
name: name,
|
||||||
|
description: "Test item",
|
||||||
|
thumbnail_url: "https://images.neopets.example/item.png",
|
||||||
|
zones_restrict: zones_restrict,
|
||||||
|
rarity: "Common",
|
||||||
|
price: 100
|
||||||
|
)
|
||||||
|
swf_asset = SwfAsset.create!(
|
||||||
|
type: "object",
|
||||||
|
remote_id: (SwfAsset.maximum(:remote_id) || 0) + 1,
|
||||||
|
url: "https://images.neopets.example/#{name}.swf",
|
||||||
|
zone: zone,
|
||||||
|
zones_restrict: zones_restrict,
|
||||||
|
body_id: body_id
|
||||||
|
)
|
||||||
|
item.swf_assets << swf_asset
|
||||||
|
item
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "Item::Appearance#compatible_with?" do
|
||||||
|
it "returns true for items in different zones with no restrictions" do
|
||||||
|
hat = create_item("Hat", zones(:hat))
|
||||||
|
shirt = create_item("Shirt", zones(:shirtdress))
|
||||||
|
|
||||||
|
appearances = Item.appearances_for([hat, shirt], @pet_type)
|
||||||
|
hat_appearance = appearances[hat.id]
|
||||||
|
shirt_appearance = appearances[shirt.id]
|
||||||
|
|
||||||
|
expect(hat_appearance.compatible_with?(shirt_appearance)).to be true
|
||||||
|
expect(shirt_appearance.compatible_with?(hat_appearance)).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false for items in the same zone" do
|
||||||
|
hat1 = create_item("Hat 1", zones(:hat))
|
||||||
|
hat2 = create_item("Hat 2", zones(:hat))
|
||||||
|
|
||||||
|
appearances = Item.appearances_for([hat1, hat2], @pet_type)
|
||||||
|
hat1_appearance = appearances[hat1.id]
|
||||||
|
hat2_appearance = appearances[hat2.id]
|
||||||
|
|
||||||
|
expect(hat1_appearance.compatible_with?(hat2_appearance)).to be false
|
||||||
|
expect(hat2_appearance.compatible_with?(hat1_appearance)).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false when one item restricts a zone the other occupies" do
|
||||||
|
# Create a hat that restricts the ruff zone (zone 29)
|
||||||
|
# The zones_restrict format is a 52-character bitstring where bit N corresponds to zone N+1
|
||||||
|
# Zones are 1-indexed, so zone 29 needs the bit at position 28 (0-indexed from right)
|
||||||
|
# Build string from right to left: 28 zeros, then "1", then 23 zeros
|
||||||
|
zones_restrict = ("0" * 23 + "1" + "0" * 28).reverse.chars.reverse.join
|
||||||
|
|
||||||
|
# Simpler approach: create a 52-char string with bit 28 set to "1"
|
||||||
|
zones_restrict_array = Array.new(52, "0")
|
||||||
|
zones_restrict_array[28] = "1" # Set bit for zone 29
|
||||||
|
zones_restrict = zones_restrict_array.join
|
||||||
|
|
||||||
|
restricting_hat = create_item("Restricting Hat", zones(:hat), zones_restrict: zones_restrict)
|
||||||
|
|
||||||
|
# Create an item in the ruff zone
|
||||||
|
ruff_item = create_item("Ruff Item", zones(:ruff))
|
||||||
|
|
||||||
|
appearances = Item.appearances_for([restricting_hat, ruff_item], @pet_type)
|
||||||
|
hat_appearance = appearances[restricting_hat.id]
|
||||||
|
ruff_appearance = appearances[ruff_item.id]
|
||||||
|
|
||||||
|
expect(hat_appearance.compatible_with?(ruff_appearance)).to be false
|
||||||
|
expect(ruff_appearance.compatible_with?(hat_appearance)).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true for empty appearances" do
|
||||||
|
# Create items that don't fit the current pet (wrong body_id)
|
||||||
|
hat = create_item("Hat", zones(:hat), body_id: 999)
|
||||||
|
shirt = create_item("Shirt", zones(:shirtdress), body_id: 999)
|
||||||
|
|
||||||
|
appearances = Item.appearances_for([hat, shirt], @pet_type)
|
||||||
|
hat_appearance = appearances[hat.id]
|
||||||
|
shirt_appearance = appearances[shirt.id]
|
||||||
|
|
||||||
|
# Both should be empty (no swf_assets for this pet)
|
||||||
|
expect(hat_appearance).to be_empty
|
||||||
|
expect(shirt_appearance).to be_empty
|
||||||
|
|
||||||
|
# Empty appearances should be compatible
|
||||||
|
expect(hat_appearance.compatible_with?(shirt_appearance)).to be true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#without_item" do
|
||||||
|
it "returns a new outfit without the given item" do
|
||||||
|
hat = create_item("Hat", zones(:hat))
|
||||||
|
outfit_with_hat = @outfit.with_item(hat)
|
||||||
|
|
||||||
|
new_outfit = outfit_with_hat.without_item(hat)
|
||||||
|
|
||||||
|
expect(new_outfit.worn_items).not_to include(hat)
|
||||||
|
expect(outfit_with_hat.worn_items).to include(hat) # Original unchanged
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns a new outfit instance (immutable)" do
|
||||||
|
hat = create_item("Hat", zones(:hat))
|
||||||
|
outfit_with_hat = @outfit.with_item(hat)
|
||||||
|
|
||||||
|
new_outfit = outfit_with_hat.without_item(hat)
|
||||||
|
|
||||||
|
expect(new_outfit).not_to eq(outfit_with_hat)
|
||||||
|
expect(new_outfit.object_id).not_to eq(outfit_with_hat.object_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does nothing if the item is not worn" do
|
||||||
|
hat = create_item("Hat", zones(:hat))
|
||||||
|
|
||||||
|
new_outfit = @outfit.without_item(hat)
|
||||||
|
|
||||||
|
expect(new_outfit.worn_items).to be_empty
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "#with_item" do
|
||||||
|
it "adds an item when there are no conflicts" do
|
||||||
|
hat = create_item("Hat", zones(:hat))
|
||||||
|
|
||||||
|
new_outfit = @outfit.with_item(hat)
|
||||||
|
|
||||||
|
expect(new_outfit.worn_items).to include(hat)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns a new outfit instance (immutable)" do
|
||||||
|
hat = create_item("Hat", zones(:hat))
|
||||||
|
|
||||||
|
new_outfit = @outfit.with_item(hat)
|
||||||
|
|
||||||
|
expect(new_outfit).not_to eq(@outfit)
|
||||||
|
expect(new_outfit.object_id).not_to eq(@outfit.object_id)
|
||||||
|
expect(@outfit.worn_items).to be_empty # Original unchanged
|
||||||
|
end
|
||||||
|
|
||||||
|
it "is idempotent (adding same item twice has no effect)" do
|
||||||
|
hat = create_item("Hat", zones(:hat))
|
||||||
|
|
||||||
|
outfit1 = @outfit.with_item(hat)
|
||||||
|
outfit2 = outfit1.with_item(hat)
|
||||||
|
|
||||||
|
expect(outfit1.worn_items.size).to eq(1)
|
||||||
|
expect(outfit2.worn_items.size).to eq(1)
|
||||||
|
expect(outfit2.worn_items).to include(hat)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not add items that don't fit this pet" do
|
||||||
|
# Create item with wrong body_id
|
||||||
|
hat = create_item("Hat", zones(:hat), body_id: 999)
|
||||||
|
|
||||||
|
new_outfit = @outfit.with_item(hat)
|
||||||
|
|
||||||
|
expect(new_outfit.worn_items).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
context "with conflicting items" do
|
||||||
|
it "moves conflicting item to closet when items occupy the same zone" do
|
||||||
|
hat1 = create_item("Hat 1", zones(:hat))
|
||||||
|
hat2 = create_item("Hat 2", zones(:hat))
|
||||||
|
|
||||||
|
outfit_with_hat1 = @outfit.with_item(hat1)
|
||||||
|
outfit_with_hat2 = outfit_with_hat1.with_item(hat2)
|
||||||
|
|
||||||
|
expect(outfit_with_hat2.worn_items).to include(hat2)
|
||||||
|
expect(outfit_with_hat2.worn_items).not_to include(hat1)
|
||||||
|
expect(outfit_with_hat2.closeted_items).to include(hat1)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "moves conflicting item to closet when new item restricts zone" do
|
||||||
|
# Create item in ruff zone
|
||||||
|
ruff_item = create_item("Ruff Item", zones(:ruff))
|
||||||
|
|
||||||
|
# Create hat that restricts ruff zone (zone 29)
|
||||||
|
# zones_restrict is 0-indexed, so zone 29 needs bit 28 to be "1"
|
||||||
|
zones_restrict_array = Array.new(52, "0")
|
||||||
|
zones_restrict_array[28] = "1"
|
||||||
|
zones_restrict = zones_restrict_array.join
|
||||||
|
restricting_hat = create_item("Restricting Hat", zones(:hat), zones_restrict: zones_restrict)
|
||||||
|
|
||||||
|
# First wear ruff item, then wear restricting hat
|
||||||
|
outfit_with_ruff = @outfit.with_item(ruff_item)
|
||||||
|
outfit_with_hat = outfit_with_ruff.with_item(restricting_hat)
|
||||||
|
|
||||||
|
expect(outfit_with_hat.worn_items).to include(restricting_hat)
|
||||||
|
expect(outfit_with_hat.worn_items).not_to include(ruff_item)
|
||||||
|
expect(outfit_with_hat.closeted_items).to include(ruff_item)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "keeps compatible items when adding new item" do
|
||||||
|
hat = create_item("Hat", zones(:hat))
|
||||||
|
shirt = create_item("Shirt", zones(:shirtdress))
|
||||||
|
pants = create_item("Pants", zones(:trousers))
|
||||||
|
|
||||||
|
outfit1 = @outfit.with_item(hat).with_item(shirt)
|
||||||
|
outfit2 = outfit1.with_item(pants)
|
||||||
|
|
||||||
|
expect(outfit2.worn_items).to include(hat, shirt, pants)
|
||||||
|
expect(outfit2.closeted_items).to be_empty
|
||||||
|
end
|
||||||
|
|
||||||
|
it "can move multiple conflicting items to closet" do
|
||||||
|
hat1 = create_item("Hat 1", zones(:hat))
|
||||||
|
hat2 = create_item("Hat 2", zones(:hat))
|
||||||
|
hat3 = create_item("Hat 3", zones(:hat))
|
||||||
|
|
||||||
|
# Wear hat1 and hat2 by manually building the outfit
|
||||||
|
# (normally you can't, but we're testing the conflict resolution)
|
||||||
|
outfit = @outfit.dup
|
||||||
|
outfit.worn_items << hat1
|
||||||
|
outfit.worn_items << hat2
|
||||||
|
|
||||||
|
# Now add hat3, which should move both hat1 and hat2 to closet
|
||||||
|
outfit_with_hat3 = outfit.with_item(hat3)
|
||||||
|
|
||||||
|
expect(outfit_with_hat3.worn_items).to contain_exactly(hat3)
|
||||||
|
expect(outfit_with_hat3.closeted_items).to contain_exactly(hat1, hat2)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "does not duplicate items in closet if already closeted" do
|
||||||
|
hat1 = create_item("Hat 1", zones(:hat))
|
||||||
|
hat2 = create_item("Hat 2", zones(:hat))
|
||||||
|
|
||||||
|
# Wear hat1
|
||||||
|
outfit1 = @outfit.with_item(hat1)
|
||||||
|
|
||||||
|
# Add hat2 (moves hat1 to closet)
|
||||||
|
outfit2 = outfit1.with_item(hat2)
|
||||||
|
|
||||||
|
# Add hat2 again (should be idempotent, not duplicate hat1 in closet)
|
||||||
|
outfit3 = outfit2.with_item(hat2)
|
||||||
|
|
||||||
|
expect(outfit3.closeted_items.size).to eq(1)
|
||||||
|
expect(outfit3.closeted_items).to include(hat1)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context "edge cases" do
|
||||||
|
it "handles nil item gracefully" do
|
||||||
|
expect { @outfit.with_item(nil) }.not_to raise_error
|
||||||
|
end
|
||||||
|
|
||||||
|
it "works with outfit that has no pet_state" do
|
||||||
|
# This shouldn't happen in practice, but let's be defensive
|
||||||
|
outfit_no_pet = Outfit.new
|
||||||
|
hat = create_item("Hat", zones(:hat))
|
||||||
|
|
||||||
|
# Should not crash, but also won't add the item
|
||||||
|
expect { outfit_no_pet.with_item(hat) }.not_to raise_error
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -38,4 +38,23 @@ RSpec.describe PetType do
|
||||||
expect(PetType.find_by_param!("123-456")).to eq pet_types(:newcolor_newspecies)
|
expect(PetType.find_by_param!("123-456")).to eq pet_types(:newcolor_newspecies)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe ".for_species_and_color" do
|
||||||
|
it('returns the exact match when it exists') do
|
||||||
|
result = PetType.for_species_and_color(species_id: species(:acara), color_id: colors(:blue))
|
||||||
|
expect(result).to eq pet_types(:blue_acara)
|
||||||
|
end
|
||||||
|
|
||||||
|
it('returns nil when species is nil') do
|
||||||
|
result = PetType.for_species_and_color(species_id: nil, color_id: colors(:blue))
|
||||||
|
expect(result).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it('falls back to a simple color when exact match does not exist') do
|
||||||
|
# Request a species that exists but with a color that might not
|
||||||
|
# It should fall back to a basic/standard color for that species
|
||||||
|
result = PetType.for_species_and_color(species_id: species(:acara), color_id: 999)
|
||||||
|
expect(result).to eq pet_types(:blue_acara)
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,15 @@
|
||||||
require_relative '../rails_helper'
|
require_relative '../rails_helper'
|
||||||
|
|
||||||
RSpec.describe Species do
|
RSpec.describe Species do
|
||||||
fixtures :species
|
fixtures :species, :colors
|
||||||
|
|
||||||
|
describe "#valid_colors_for_species" do
|
||||||
|
it('returns colors that have pet types for the species') do
|
||||||
|
# The Blue Acara exists in fixtures, as does a "Color #123 Acara", which we'll ignore.
|
||||||
|
compatible_colors = species(:acara).compatible_colors
|
||||||
|
expect(compatible_colors.map(&:id)).to eq [8]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
describe '#to_param' do
|
describe '#to_param' do
|
||||||
it("uses name when possible") do
|
it("uses name when possible") do
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue