Commit Graph

13 Commits

Author SHA1 Message Date
Inna cccc70338d Quitar el prefijo home_ de las 41 tablas del portal
El prefijo venía del nombre de la app Django (`home`), jubilada y borrada hoy,
así que ya no significaba nada: home_stripelog -> stripelog. La BD se sigue
llamando django_wow por historia (renombrarla es otra operación).

Comprobado antes de tocar nada: 0 colisiones con nombres existentes, 0 palabras
reservadas (contrastado contra las 260 de MySQL), sin vistas ni triggers que
dependieran de ellas. El RENAME va en una sola sentencia porque así es atómico,
y MySQL reapunta solo las 4 claves ajenas entre estas tablas.

170 referencias actualizadas en 37 ficheros. Se dejó a propósito
sql/drop_home_securitytoken_authuser_fk.sql sin tocar: es una migración ya
aplicada y reescribirla sería falsear el historial.

De paso salió un fallo previo: prices.ts consultaba `home_restoreitemprice`,
una tabla que NO existe. El try/catch de priceFrom se comía el error y devolvía
siempre el precio por defecto, así que el precio de restaurar objetos nunca fue
configurable. Se deja documentado en el código; crear la tabla es otra decisión.

Verificado tras aplicar: 0 tablas home_, las 51 siguen ahí, y el conteo exacto
de filas cuadra con el volcado previo (store_item 3068, item_data 44873,
stripelog 35, store_order 26, noticia 50). La web responde 200 en todas las
rutas y la portada carga las noticias sin errores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 14:49:19 +00:00
Inna 67faa1a266 send-gift: rediseño al original — es la tienda, pero regalando
El original lo dice en su propio texto ("los objetos disponibles son los mismos
de la tienda") y lo confirma su JS, que usa #store-list y .store-add-button: el
regalo NO tiene catálogo propio, es la tienda con el correo a otro personaje.
Nosotros teníamos una tabla aparte (home_item) con 8 objetos y precios en euros.

Ahora send-gift monta StoreBrowser en modo `gift`: mismo catálogo (3068 ítems,
PD/PV), mismo carrito con cantidades y las cuatro formas de pago. Antes NO tenía
Stripe: solo SumUp, PD y PV.

Dos pasos, como el original: primero #char-select-div (personaje de origen,
destino, confirmación y token) y solo al pulsar "Mostrar Regalos" aparece el
catálogo. Se borran SendGiftForm, /api/gift/checkout y lib/gift (ya no los usa
nadie); la tabla home_item se queda en la BD, sin usar.

Tres cosas que el usuario pidió y que estaban mal:
- El formulario va con `noValidate`: los avisos los damos nosotros en rojo
  (#show-gif-response), no el navegador.
- "Mostrar Regalos" ahora valida contra el SERVIDOR (que el personaje de origen
  sea tuyo, que el destino exista y que el token sea correcto). Antes elegías
  objetos para descubrir al final que el destino no existía.
- BUG REAL: el nombre del destino distinguía mayúsculas. `characters.name` es
  `utf8mb4_bin`, así que "innakh" NO encontraba a "Innakh" (comprobado: 0
  resultados; con COLLATE, 1). `findCharacterByName` busca sin distinguir y
  devuelve el nombre CANÓNICO, que es el que va al comando SOAP.

La validación vive en lib/gift-check y la usan las dos rutas: /api/gift/check (el
botón) y /api/gift/send, que revalida porque el cliente puede saltarse el paso 1.

También se quita del texto "El pago se realiza mediante SumUp": el original no lo
dice y además ya era falso con cuatro formas de pago.

Verificado: innakh / INNAKH / InNaKh resuelven a "Innakh"; token malo y destino
inexistente salen en rojo sin pasar al catálogo. Con 500 PD de saldo de prueba,
2 copias de un ítem de 200 pasan el cobro (deliveryFailed por el worldserver
caído, con su reembolso) y 3 dan insufficientPd: el servidor cobra por copias.
Datos de prueba borrados.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 12:45:01 +00:00
Inna d3461976cf store: el concepto del cobro incluye también la IP
Tienda · BNet inna@inna.cl · Cuenta 15#1 · Innadin · IP 213.94.48.99 · Grito de la sirena (45327) x2

La IP ya se capturaba, pero solo se guardaba en home_stripelog.acore_ip. OJO:
meterla en el concepto SÍ se la manda a la pasarela, cosa que antes no pasaba
(ni Stripe ni SumUp la recibían). Va a propósito, para identificar al pagador en
su panel si hay fraude o una devolución a mano.

Se omite si no se conoce y también el 0.0.0.0 de relleno, que no aporta nada.

Verificado contra checkouts reales de Stripe (test): IPv4 con 1 ítem = 100
chars; IPv6 (39 chars) con 1 ítem = 124; IPv6 con 30 ítems = 172, recortando los
ítems. Todos por debajo del límite de 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:41:27 +00:00
Inna 18fe2489d0 store: el concepto del cobro dice quién compra y qué
Ponía "Compra en la tienda (1 objeto)", que no identifica nada. Ahora:

  Tienda · BNet inna@inna.cl · Cuenta 15#1 · Innadin · Grito de la sirena (45327) x2

Cuenta Battle.net, cuenta de juego, personaje, y el nombre de cada ítem con su
id. Es lo único que Stripe/SumUp enseñan en su panel, así que es lo que hay para
identificar un pago cuando toca reclamar o devolver algo a mano.

Se recorta a 200 caracteres porque las dos pasarelas limitan el campo (~250) y
un carrito grande se pasaría. Se recortan solo los ÍTEMS ("+37 más"), nunca la
cabecera: quién ha pagado importa más que el listado, que además queda entero en
home_store_order. El recorte quita ítems hasta que quepa TODO con el sufijo
incluido; comprobarlo antes de añadir el "+N más" se pasaba igual (206 chars con
12 ítems, visto en un checkout real).

priceStoreCart trae ahora también el nombre del ítem, que no leía.

Verificado contra checkouts reales de Stripe (test) con 1, 12 y 40 ítems: 82,
167 y 168 caracteres.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:36:14 +00:00
Inna 24248bc603 store: el árbol de categorías, igual que el original (colores y sin fantasmas)
Investigadas las 750 categorías de la BD contra el HTML real del original. Los
ítems estaban perfectos (554 hojas, 3068 ítems, mismo contenido en cada una: no
faltaba ninguno). Todo lo roto era de categorías:

- 45 CATEGORÍAS FANTASMA, todas con nombre de código ("11-1", "23-10"). El seed
  dedujo el árbol de los códigos de las hojas ("11-1-1" => padre "11-1"), pero
  el original NO siempre pinta ese nivel: en 45 hojas el "-1-" del código no
  corresponde a nada y cuelgan directas de su raíz. Ahora se lee el anidamiento
  real (<ul>/<li>): 705 categorías (20 + 131 + 554) en vez de 750.
- El nombre de una categoría NO es texto plano, es HTML, y lo estábamos
  aplanando: 141 nombres perdían su color y 40 cabeceras el suyo. Hay dos
  mecanismos y los dos hacen falta: la clase en la CABECERA, que sustituye a
  first-brown para colorear la categoría entera ("Brujo" en color de brujo), y
  los <span class="no-toggle X"> dentro del nombre (clases y las etiquetas
  [Nivel 232 a 245] / (DPS) en otro tono).
- 21 nombres TRUNCADOS en Tokens de Armadura: guardábamos "Guerrero" donde el
  original dice "Guerrero, Sacerdote, Druida" (solo se quedó el primer span).

El HTML se trocea en el SERVIDOR (`parseCategoryName`) y al cliente le llegan
solo texto + nombre de clase, así que no hay HTML de la BD entrando en el DOM.
El texto sin color va suelto, NO envuelto en <span>: el tema tiene
`span { color: #fff }` y lo pondría blanco en vez de heredar el de la cabecera.

`name` pasa a VARCHAR(512): con el HTML, el más largo mide 198 y no cabía en 191.
El name_en se genera en el propio seed traduciendo solo el TEXTO y respetando
las etiquetas, así que sql/store_category_en.sql sobra y se borra.
gen_store_catalog.py regenera el seed entero (categorías del HTML, ítems de la
BD, que ya estaban verificados) y es reproducible byte a byte.

Verificado en el navegador contra el original: mismo árbol, 0 categorías con
nombre de código, "[Nivel 232 a 245]" en marrón tenue y los colores de clase
oficiales (Guerrero #C79C6E, Sacerdote blanco, Druida #FF7D0A). En inglés
también, conservando los colores.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 11:24:19 +00:00
Inna fb28e97307 store: devolver por Stripe/SumUp lo no entregado de una compra con tarjeta
Faltaba la otra mitad de 0be3dc5: con saldo ya se reembolsaba lo que no llegaba
a enviarse, pero con tarjeta el dinero lo tiene la pasarela y el cliente se
quedaba pagando de más. Ambas tienen API de reembolso parcial, así que ahora
`fulfill` puede pedir que se devuelva un importe y lo hace quien conoce la
pasarela (lib/fulfill para Stripe, lib/sumup para SumUp).

Para saber CUÁNTO devolver hay que saber qué costó cada entrada, así que el
pedido pasa a guardarse como `itemId:qty:precio:moneda`. Releer el precio del
catálogo al entregar no vale por dos razones: 114 item_id son ambiguos (el mismo
objeto se vende a 50 PV y a 100 PD), y hay que devolver lo que se COBRÓ, no lo
que valga el ítem el día de la entrega. Los pedidos con el formato viejo se
entregan igual, pero no se puede calcular su reembolso: se registra para hacerlo
a mano.

`fulfill` devuelve ahora `boolean | {ok, refundEur}`; los servicios que no
entregan a medias siguen devolviendo boolean y no se tocan.

Sobre SumUp, todo comprobado contra su API en sandbox y nada de esto está en
sitios obvios:
 - El reembolso NO va por referencia de checkout sino por transacción.
 - Hay que usar el endpoint de v1.0; el viejo `/v0.1/me/refund/{txn}` responde
   409 a CUALQUIER reembolso parcial (el total sí funciona).
 - v1.0 quiere el importe en CÉNTIMOS y ENTERO, al revés que el resto de la API
   v0.1, que usa euros. Y mandar 0.10 en vez de 10 no da error: se trunca a 0,
   SumUp lo lee como "sin importe" y DEVUELVE EL PAGO ENTERO. Casi me lo comí.
 - Hay un mínimo por reembolso (20 cts en esta cuenta, la API lo dice en
   `min_refundable_amount`): por debajo responde 400 y se registra para hacerlo
   a mano.
El host sale de SUMUP_API_BASE (por defecto api.sumup.com) en vez de ir a fuego.

Verificado de punta a punta con pagos reales de prueba, parcheando el SOAP para
que solo saliera el primer correo:
 - Stripe (sk_test): pagados 1,30 €, entregadas 12/13 -> reembolso de 0,10 €,
   status succeeded en la API de Stripe.
 - SumUp (sandbox): pagados 1,50 €, entregadas 12/15 -> evento REFUND 0.3
   REFUNDED en la API de SumUp.

De paso: el concepto del cobro decía "1 objeto" al comprar 15 copias (contaba
líneas, no copias).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:57:00 +00:00
Inna 0be3dc5280 store: reembolsar solo lo no entregado si el envío falla a medias
Un carrito de más de 12 entradas se envía en varios correos. Si fallaba el 3.º
de 9, los 2 primeros ya estaban en el buzón del jugador y aun así se le devolvía
el carrito ENTERO: se quedaba los objetos gratis. Con las cantidades por línea
(f232f0f) llegar ahí es fácil: 100 copias son 9 correos.

sendStoreItems devuelve ahora CUÁNTAS entradas entregó en vez de un booleano: un
correo enviado no se puede deshacer, así que quien llama necesita saber dónde se
cortó. Cada `.send items` es un correo, así que el troceo es atómico por correo:
un chunk sale entero o no sale. purchaseStoreCart reembolsa solo las entradas
que quedaron sin enviar (cada una lleva su precio y su moneda) y devuelve
`partialDelivery`, con un mensaje que dice la verdad: parte llegó al correo del
juego y solo se ha devuelto el resto. Si no sale ni un correo, sigue siendo el
`deliveryFailed` de siempre con la devolución completa.

En el pago con tarjeta no se puede hacer lo mismo (el dinero ya lo cobró la
pasarela): fulfillStoreOrder devuelve false si no salió todo. No puede duplicar
porque claimPaidCheckout reclama el pago una sola vez, pero por eso mismo
tampoco hay reintento y un envío a medias hay que rescatarlo a mano desde
home_store_order. Queda anotado en el código.

Verificado de punta a punta, no solo razonado: con un parche temporal del SOAP
que deja salir el 1.er correo y tumba el resto, 13 copias de un ítem de 10 PD
(130 PD, 12+1 entradas) dejan el saldo en 500 -> 380, o sea 500-130+10: se
devuelve SOLO la entrada que no salió y se cobran las 12 entregadas. El parche
se revirtió y producción se reconstruyó limpia.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:19:09 +00:00
Inna f232f0fc5f store: poder cambiar la cantidad de un mismo artículo en el carrito
El carrito era un conjunto: añadir dos veces el mismo ítem no hacía nada y el
botón se quedaba en "Añadido". Ahora cada línea lleva sus copias, editables con
un <input number> en la columna Cant, y volver a pulsar Añadir suma una (mismo
patrón que el carrito de send-gift, que ya lo hacía así).

Ojo con los dos "cantidad" que conviven, que no son lo mismo:
`home_store_item.quantity` es el LOTE que entrega cada copia (Paño de lino = 20)
y `copies` es cuántas veces se compra la línea. Por eso la fila enseña "3 × 20"
y el total de la columna Cant sigue siendo unidades (60), como en el original.
Cada copia se manda al SOAP como una entrada propia (`2589:20 2589:20`), que es
justo lo que ya sabía trocear sendStoreItems (12 por correo) y parsear
fulfillStoreOrder: el formato del pedido de tarjeta no cambia.

El servidor no se fía del cliente: priceStoreCart recibe {id, copies}, valida las
copias (1..MAX_COPIES, como el MAX_QTY de send-gift), rechaza el carrito si algún
id no existe y recalcula los totales como precio*copias contra la BD.

Verificado contra la API con saldo de prueba, no solo en la interfaz: con 500 PD
y un ítem de 200, 2 copias (400) pasan el cobro y 3 (600) dan insufficientPd. Si
el servidor ignorase las copias ambas darían lo mismo, así que el corte exacto
entre 2 y 3 prueba que multiplica. Comprobado también que el reembolso del
deliveryFailed devuelve los 400 PD, y que copias=0 o 101 se rechazan.

El CSS del input va con `#cart-list` por especificidad, no por gusto: el tema
define `input[type=number] { width: 290px }` y se carga el último para ganar la
cascada, así que un `.store-copies` a secas perdía y el input salía de 290px
reventando la tabla (send-gift se libra porque usa estilo en línea).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 10:10:33 +00:00
Inna e500bd7a84 store: precios en euros en el carrito y mínimo real de las pasarelas
El carrito enseñaba PD/PV aunque eligieras tarjeta, y por defecto ya presuponía
saldo. Ahora se muestran SIEMPRE las dos monedas y se apaga la que no se va a
cobrar con el método elegido, así que no presupone nada ni se contradice.

Los euros por línea van con hasta 3 decimales a propósito: un ítem en PV con
precio impar cuesta medio céntimo (75 PV = 0,375 €), y redondear cada línea a 2
haría que las líneas no sumaran el total (0,38 + 0,38 = 0,76 contra un total de
0,75). El total sí va a céntimos: es el importe que se cobra de verdad. Y con
mínimo 2 decimales, que es dinero: "1,50 €", no "1,5 €". El formateo va por
Intl con el locale de la web, así que en español sale con coma; antes el
selector interpolaba el número crudo y decía "0.75 €" con punto.

Mínimos de las pasarelas: SumUp ya estaba en 1 € y Stripe estaba en 0,50 €,
ahora también 1 €. Estaban sueltos en la ruta; se centralizan en
`lib/store-pricing`, un módulo PURO que importan tanto la ruta como el
componente, para que el carrito enseñe exactamente lo que se valida y se cobra.
`storeEuroTotal` pasa a vivir ahí (una sola implementación) y lib/store lo
envuelve con un guard: si alguien cambia PD_PER_UNIT o VP_PRICE_FACTOR sin tocar
store-pricing, revienta al primer uso en vez de cobrar mal en silencio (esos
módulos tocan BD y no pueden llegar al bundle del cliente, de ahí la
duplicación, igual que en PaymentMethodSelect).

Por debajo del mínimo, la opción de tarjeta se desactiva y dice "Mínimo 1,00 €"
en vez de dejarte pulsar Enviar para que la API la rechace. El método efectivo
se deriva en el render (si el carrito baja del mínimo con tarjeta ya elegida,
cae al saldo) en vez de sincronizarlo con un setState en un efecto, que
provocaría renders en cascada.

Verificado en el navegador: con 0,375 € las dos tarjetas salen desactivadas y
elegido el saldo; con 1,50 € se habilitan. Las dos líneas de 0,375 € suman el
total de 0,75 €.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 09:55:27 +00:00
Inna bb0f4f21c1 store: categorías en inglés (términos oficiales sacados de los DB2)
Cierra el idioma de /en/store: los nombres de ítem ya salían en inglés
(2daf920), pero el árbol de categorías seguía en español y la tienda se veía a
medias.

Los términos DEL JUEGO no se traducen a mano, se sacan del cliente, para que
casen con lo que ve un jugador en inglés:

- ChrClasses  -> clases (Brujo = Warlock, Pícaro = Rogue...)
- ItemSet     -> sets (Vestiduras de la Némesis = Nemesis Raiment, Las diez
                 tormentas = The Ten Storms, Atavío viento abisal = Netherwind
                 Regalia). Casando sin distinguir mayúsculas: las categorías
                 capitalizan distinto que los ItemSet.
- ItemSubClass-> tipos de arma (Armas de asta = Polearms, Varitas = Wands)
- home_item_data -> lo que no es ItemSet. Los sets de PvP de WotLK no son
                 ItemSets reales, así que "Gladiador furioso" = "Furious
                 Gladiator" sale de los nombres bilingües de sus propios ítems.

Solo se traduce a mano lo que la tienda se inventó y no existe en el juego
(Items de leveo = Leveling Items, Moneda de transfiguración = Transmog
Currency), más las fórmulas (Nivel de objeto N = Item Level N, Set de banda
tier N = Tier N Raid Set). Los ~45 nombres que son solo un código ("11-1") y
los que ya estaban en inglés ("Tier 7.1 (PvE)") se dejan igual.

wdc_db2.py aprende a leer strings de tablas no sparse: el valor guardado no es
un índice a la string table sino un offset relativo a la posición del propio
campo. Verificado con ChrClasses (las 10 clases en ambos idiomas).

Revisadas las 218 a mano. Corregida una que me había inventado: "Equipo de
Batalla del Iluminado de la Fuente del Sol" no es "Sunwell Radiance"
(eso es un buff): "Iluminado" es Lightbringer en el ItemSet y "Fuente del Sol"
es Sunwell según sus ítems -> "Sunwell Lightbringer Battlegear".

OJO, bug de datos preexistente: "Sacedorte" (errata de "Sacerdote") en las
categorías 3-10, 4-10 y 19-9; el 20-9 sí está bien. Se ve así en español. El
name_en va a "Priest"; la errata en español NO se toca.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:45:24 +00:00
Inna 2daf920c01 store: nombres de ítem en el idioma de la web (desde home_item_data)
El catálogo original solo tiene el nombre en español, así que /en/store venía
mostrando los ítems en español. Ahora salen de `home_item_data` (extraída de
los DB2 del cliente, commit 4ba9c42), que es el único sitio con el nombre en
inglés.

- getStoreCatalog(locale) hace LEFT JOIN con home_item_data y elige name_es o
  name_en. Si falta el nombre en ese idioma se cae al del catálogo, que siempre
  está, así que ningún ítem puede quedarse sin nombre.
- La columna se elige de una lista cerrada ('en' -> name_en, resto -> name_es):
  nunca se interpola el locale crudo, que viene de la URL.
- /api/store/catalog recibe el locale por query: la ruta no cuelga de [locale].

Verificado en los dos idiomas: 45327 sale "Grito de la sirena" en /es y
"Siren's Cry" en /en. El árbol sigue devolviendo las 3068 filas y ninguna sin
nombre (el JOIN no duplica: `entry` es clave primaria).

Pendiente: los nombres de las CATEGORÍAS siguen solo en español
(home_store_category.name), así que /en/store se ve a medias. No son datos del
juego, así que los DB2 no ayudan: hay que traducir 218 nombres distintos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:31:57 +00:00
Inna f0b006404c store: pagar también con Stripe y SumUp (tarjeta), además del saldo PD/PV
Selector de forma de pago en el carrito (Saldo PD/PV · Stripe · SumUp), como el
resto de servicios. Con tarjeta, el carrito se convierte a euros (100 PD = 1 €,
200 PV = 1 €), se guarda como pedido y se entrega tras confirmarse el pago.

- BD: tabla home_store_order (ref, cuenta, personaje, items, importe) para no
  depender del límite de metadata de la pasarela (sql/home_store_order.sql).
- lib/store: storeEuroTotal, createStoreOrder, fulfillStoreOrder.
- paid-services: servicio 'store' (fulfill = enviar el pedido por order_ref);
  la entrega la dispara service-success/reconciliación/webhook como los demás.
- /api/store/send: rama provider stripe/sumup (crea pedido + checkout, mínimo
  1 €/0,50 €) además del pago con saldo.
- StoreBrowser: selector Saldo/Stripe/SumUp; con tarjeta redirige a la pasarela.
- i18n Store (payMethod/payBalance/payStripe/paySumUp/confirmCard/amountTooLow)
  y Paid.store (title/success). Verificado: euros 200PD+28PV=2,14 €, pedido y
  fulfill por order_ref (SOAP real pendiente, worldserver caído).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 00:14:01 +00:00
Inna 026fbcb81c Implementa la tienda /store (catálogo BENNU: 750 categorías, 3068 ítems)
Porta la tienda de ítems/transfiguración del sistema antiguo: elegir personaje →
árbol de categorías (3 niveles) → carrito → enviar por correo. Se paga con el
saldo PD o PV de la cuenta (cada ítem lleva su precio y moneda).

- BD: home_store_category (árbol por code "1-1-1") + home_store_item
  (mismos item_id, nombres, iconos, precios y moneda que el original;
  2947 en PD, 121 en PV). Seed en sql/store_catalog.sql, extraído del HTML real.
- lib/store.ts: getStoreCatalog (árbol anidado), getStoreBalances, priceStoreCart
  (precios REALES de BD), purchaseStoreCart (cobro atómico PD+PV con FOR UPDATE y
  reembolso si el envío SOAP falla; `.send items` troceado a 12/correo).
- API: GET /api/store/catalog (tras elegir personaje) y POST /api/store/send.
- components/StoreBrowser.tsx: selector de personaje, árbol colapsable (ítems
  montados solo al abrir), carrito con totales PD/PV, enviar, modal de resultado.
- página /store con la info y el título "Tienda - REINO". Enlaces de ítems e
  iconos por wowhead/zamimg (con tooltip y locale). El enlace ya existía en
  AccountTools. i18n namespace Store (es/en). CSS del árbol/carrito/modal.

Verificado: catálogo E2E (750 cats/3068 ítems), cobro atómico + reembolso y
detección de saldo insuficiente (cuenta 15). SOAP real sin probar (worldserver
caído en este entorno).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 23:54:57 +00:00